Compare commits

...

95 Commits

Author SHA1 Message Date
Beto Dealmeida
b11ac4dd90 chore: container for testing 2026-02-10 15:39:50 -05:00
Beto Dealmeida
e182520bb3 feat: Explore integration 2026-02-10 15:39:50 -05:00
Beto Dealmeida
bfa4d5bd92 feat: models and DAOs 2026-02-10 15:38:15 -05:00
Beto Dealmeida
0e9c71e283 chore: remove AdhocFilter 2026-02-10 11:23:53 -05:00
Beto Dealmeida
5c1e250b77 feat: semantic layer extension 2026-02-09 15:10:06 -05:00
dependabot[bot]
87d15d32c4 chore(deps-dev): bump @types/node from 25.2.0 to 25.2.1 in /superset-frontend (#37732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 20:16:18 +07:00
dependabot[bot]
7d9a8a0c5a chore(deps-dev): bump @babel/node from 7.28.6 to 7.29.0 in /superset-frontend (#37734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 20:15:47 +07:00
dependabot[bot]
ddba88ffad chore(deps): bump googleapis from 171.2.0 to 171.4.0 in /superset-frontend (#37736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 20:15:29 +07:00
Evan Rusackas
1e50422a66 chore: remove deprecated react-hot-loader (#36433)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-06 15:57:31 +07:00
Evan Rusackas
246dbd7f5c chore(deps): upgrade react-resize-detector to v9.1.1 (#37741)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:25:30 +07:00
dependabot[bot]
9b861b2848 chore(deps): bump caniuse-lite from 1.0.30001768 to 1.0.30001769 in /docs (#37726)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 14:33:57 +07:00
dependabot[bot]
1c35c3f6d0 chore(deps): bump markdown-to-jsx from 9.7.2 to 9.7.3 in /superset-frontend (#37730)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 14:31:39 +07:00
dependabot[bot]
b71654877f chore(deps-dev): bump @types/node from 25.2.0 to 25.2.1 in /superset-websocket (#37719)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 14:31:09 +07:00
dependabot[bot]
cd447ca1fd chore(deps): update @luma.gl/webgl requirement from ~9.2.2 to ~9.2.6 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#37469)
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@rusackas.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:39:25 -08:00
Amin Ghadersohi
01ac966b83 fix(mcp): remove html.escape to fix ampersand display in chart titles (#37186)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:49:37 -08:00
dependabot[bot]
97e5f0631d chore(deps): bump aws-actions/configure-aws-credentials from 5 to 6 (#37685)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 19:48:51 -08:00
dependabot[bot]
b7acb7984f chore(deps-dev): bump @babel/core from 7.28.6 to 7.29.0 in /superset-frontend (#37686)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 19:48:43 -08:00
Evan Rusackas
d3919cf24f fix(translations): Periodic language strings extraction, newly Translatable label positions for Radar Chart (#33940)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:48:24 -08:00
dependabot[bot]
27889651b3 chore(deps): bump markdown-to-jsx from 9.6.1 to 9.7.2 in /superset-frontend (#37691)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 09:55:51 +07:00
Đỗ Trọng Hải
361fe6fe89 chore(build): add @hainenber as codeowner for GHA workflow changes (#37703)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-06 09:54:21 +07:00
dependabot[bot]
8506d70242 chore(deps-dev): bump webpack from 5.94.0 to 5.105.0 in /superset-embedded-sdk (#37713)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 09:53:35 +07:00
Evan Rusackas
00a53eec2d fix(translations): remove corrupted text from Spanish translation file (#37717)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 09:52:14 +07:00
Joe Li
5040db859c test(playwright): additional dataset list playwright tests (#36684)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-05 16:42:07 -08:00
Evan Rusackas
ef4f7afa90 chore(docs): improve build performance and fix OOM crashes (#37588)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 13:12:46 -08:00
Amin Ghadersohi
47db185e3b fix(mcp): include x_axis column in query context for series charts with group_by (#37639) 2026-02-05 19:59:44 +01:00
Joe Li
2e463078a2 refactor(filters): extract shouldShowTimeRangePicker and improve test coverage (#36012)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-05 10:48:55 -08:00
JUST.in DO IT
4f42928b34 fix(sqllab): Skip progress bar on no data (#37652) 2026-02-05 10:38:37 -08:00
Gabriel Torres Ruiz
75fa474fce test(native-filters): add unit tests for requiredFirst filter logic (#37640) 2026-02-05 10:36:35 -08:00
dependabot[bot]
fd8c21591a chore(deps-dev): update @babel/types requirement from ^7.28.6 to ^7.29.0 in /superset-frontend/plugins/plugin-chart-pivot-table (#37603)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-05 10:31:56 -08:00
Amin Ghadersohi
4147d877fc fix(mcp): prevent DATE_TRUNC on non-temporal columns in chart generation (#37433) 2026-02-05 09:24:31 -08:00
Amin Ghadersohi
a9dca529c1 fix(mcp): treat runtime validation warnings as informational, not errors (#37214)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:23:51 -08:00
dependabot[bot]
20f1918dd6 chore(deps): bump caniuse-lite from 1.0.30001767 to 1.0.30001768 in /docs (#37684)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 22:26:53 +07:00
dependabot[bot]
c09a4f6f47 chore(deps): bump googleapis from 171.1.0 to 171.2.0 in /superset-frontend (#37690)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 21:37:26 +07:00
bikashbarua
4e4fa53c8d fix: Rename Truncate Axis to Truncate Y Axis in bar chart controls (#37403)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: SBIN2010 <Sbin2010@mail.ru>
2026-02-05 12:55:51 +03:00
Miguel
07ff82f189 docs: add XNET to INTHEWILD list (#37615)
Co-authored-by: Miguel Deus <miguel@xnet.company>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-02-04 21:27:35 -08:00
dependabot[bot]
b7b9bfd3fe chore(deps): bump query-string from 6.14.1 to 9.3.1 in /superset-frontend (#37545)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-04 21:26:28 -08:00
dependabot[bot]
b968d1095c chore(deps): bump dawidd6/action-download-artifact from 12 to 14 (#37602)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 21:25:52 -08:00
Michael S. Molina
e10237fcc1 fix: Security vulnerability in Storybook (#37676) 2026-02-04 14:48:39 -03:00
Michael S. Molina
92438322c0 feat(extensions): Enhances SQL Lab API (#37642) 2026-02-04 13:53:58 -03:00
Đỗ Trọng Hải
f96e90b979 fix(docker): remove accidental command substitutions when building FE in dev mode (#37670) 2026-02-04 23:53:20 +07:00
dependabot[bot]
b464979db1 chore(deps-dev): bump webpack from 5.104.1 to 5.105.0 in /superset-frontend (#37658)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 22:47:12 +07:00
dependabot[bot]
45f883c9cd chore(deps-dev): bump webpack from 5.104.1 to 5.105.0 in /docs (#37656)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 21:35:51 +07:00
Nancy Chauhan
8fd3401077 fix(security): update jspdf to 4.0.0 to address CVE-2025-68428 (#37553) 2026-02-04 21:29:57 +07:00
Richard Fogaca Nienkotter
89a98ab9a4 fix(dataset-editor): include calculated columns in currency code dropdown (#37621) 2026-02-04 11:17:07 -03:00
Jamile Celento
2dfc770b0f fix(native-filters): update TEMPORAL_RANGE filter subject when Time Column filter is applied (#36985) 2026-02-04 12:37:17 +03:00
Vanessa Giannoni
6b7b23ed78 fix(timeseries): restore ECharts tooltip after closing drill menu (#37284) 2026-02-04 12:32:23 +03:00
dependabot[bot]
5ac5480f35 chore(deps): bump caniuse-lite from 1.0.30001766 to 1.0.30001767 in /docs (#37601)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 12:35:32 -08:00
Evan Rusackas
76889c1a69 feat(db_engine_specs): add Apache Phoenix and Apache IoTDB engine specs (#37590)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:29:19 -05:00
Evan Rusackas
569606635b docs(databases): add Supabase, AlloyDB, and Neon as PostgreSQL-compatible databases (#37589)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 12:22:49 -08:00
dependabot[bot]
66264856a7 chore(deps): bump googleapis from 171.0.0 to 171.1.0 in /superset-frontend (#37630)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 22:28:07 +07:00
dependabot[bot]
3eb860a663 chore(deps): bump hot-shots from 13.1.0 to 13.2.0 in /superset-websocket (#37596)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 22:19:07 +07:00
dependabot[bot]
a44980da65 chore(deps-dev): bump globals from 17.2.0 to 17.3.0 in /superset-websocket (#37595)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 22:18:33 +07:00
dependabot[bot]
7112bce961 chore(deps-dev): bump @types/node from 25.1.0 to 25.2.0 in /superset-websocket (#37597)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 22:18:14 +07:00
dependabot[bot]
568486a304 chore(deps): bump @babel/core from 7.28.6 to 7.29.0 in /docs (#37598)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 22:17:50 +07:00
dependabot[bot]
fea135b46c chore(deps-dev): bump @playwright/test from 1.58.0 to 1.58.1 in /superset-frontend (#37633)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 22:17:13 +07:00
dependabot[bot]
601fcb3382 chore(deps-dev): bump @babel/preset-env from 7.28.6 to 7.29.0 in /superset-frontend (#37635)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 22:02:46 +07:00
dependabot[bot]
0d7cc88b2b chore(deps): bump antd from 6.2.2 to 6.2.3 in /docs (#37629)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 21:50:13 +07:00
Vitor Avila
32ee160c75 chore: Properly untrack WebSocket config file for docker (#37624) 2026-02-03 11:48:08 -03:00
Amin Ghadersohi
5914e83436 chore(mcp): remove unused MCP_SERVICE feature flag (#37618) 2026-02-03 15:23:08 +01:00
Amin Ghadersohi
0b5e4dd5de feat(mcp): add config toggle to disable parse_request decorator (#37617)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:22:44 +01:00
Joe Li
3a565a6c16 fix(tests): update DatasetList tests to new fetch-mock API (#37623)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:15:58 +03:00
Ramiro Aquino Romero
f60c82e4a6 fix: charts row limit warning is missing for server (#37112) 2026-02-02 15:49:31 -08:00
Luis Sánchez
91131d5996 chore(charts): echarts left padding too big and automation of title (#36993) 2026-02-02 15:48:03 -08:00
Joe Li
4b0d497513 test: add new RTL and integration tests for DatasetList (#36681)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-02 12:08:38 -08:00
Joe Li
86f690d17f fix(dashboard): fix Export as Example with app prefix and enable Dashboard Export E2E tests (#37529)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:07:22 -08:00
Elizabeth Thompson
e9b494163b refactor(db): use Dialect instead of Engine in select_star to avoid SSH tunnels (#35540)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-02 10:26:35 -08:00
JUST.in DO IT
be404f9b84 fix(dashboard): Avoid calling loadData for invisible charts on virtual rendering (#37452) 2026-02-02 10:07:25 -08:00
Daniel Vaz Gaspar
11257c0536 fix(examples): skip URI safety check for system imports (#37577)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:24:16 -08:00
Beto Dealmeida
f2b6c395cd feat: Add PWA file handler for CSV/XLS/Parquet uploads (#36191)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 11:24:01 -05:00
dependabot[bot]
2d35ed2391 chore(deps-dev): bump @babel/runtime-corejs3 from 7.28.6 to 7.29.0 in /superset-frontend (#37605)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 23:03:59 +07:00
dependabot[bot]
bd65469091 chore(deps-dev): bump globals from 17.2.0 to 17.3.0 in /docs (#37599)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 21:48:25 +07:00
Kamil Gabryjelski
a6a66ca483 feat: Dataset folders editor (#36239) 2026-02-02 14:54:33 +01:00
Jonathan Alberth Quispe Fuentes
4a7cdccdad fix: Heatmap does not render correctly on normalization (#37208) 2026-02-02 12:34:46 +03:00
dependabot[bot]
61bd8f0cf2 chore(deps): bump use-query-params from 1.2.3 to 2.2.2 in /superset-frontend (#36997)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-01 23:55:39 -08:00
Evan Rusackas
ae10e105c2 fix(chart): enable cross-filter on bar charts without dimensions (#37407)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 17:14:24 -08:00
dependabot[bot]
901dca58f7 chore(deps): bump JustinBeckwith/linkinator-action from 2.3 to 2.4 (#37562)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-31 03:06:30 -08:00
dependabot[bot]
d95a3d8426 chore(deps-dev): bump @applitools/eyes-storybook from 3.63.9 to 3.63.10 in /superset-frontend (#37566)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-31 03:06:09 -08:00
alok kumar priyadarshi
70b95ca1b9 fix(build): eliminate PostgreSQL extra installation on Python 3.12-based Superset Docker images (#37587) 2026-01-31 15:54:19 +07:00
Michael S. Molina
004f02746f fix(build): Increase ForkTsCheckerWebpackPlugin memory limit to fix OOM error (#37583) 2026-01-31 14:22:17 +07:00
Beto Dealmeida
5d20dc57d7 feat(oauth2): add PKCE support for database OAuth2 authentication (#37067) 2026-01-30 23:28:10 -05:00
Beto Dealmeida
05c2354997 feat: AWS Cross-Account IAM Authentication for Aurora (#37585) 2026-01-30 19:18:34 -05:00
Vitor Avila
6043e7e7e3 fix: more DB OAuth2 fixes (#37398) 2026-01-30 21:11:26 -03:00
Amin Ghadersohi
1ee14c5993 fix(mcp): improve prompts, resources, and instructions clarity (#37389) 2026-01-30 12:25:38 -08:00
Felipe López
9764a84402 fix(charts): Table chart shows an error on row limit (#37218) 2026-01-30 11:45:50 -08:00
JUST.in DO IT
570cc3e5f8 feat(sqllab): treeview table selection ui (#37298) 2026-01-30 11:07:56 -08:00
dependabot[bot]
66519c3a85 chore(deps-dev): bump fetch-mock from 11.1.5 to 12.6.0 in /superset-frontend/packages/superset-ui-core (#36662)
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: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-01-30 21:27:35 +07:00
dependabot[bot]
1f43138888 chore(deps): bump babel-loader from 9.2.1 to 10.0.0 in /docs (#37541)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 21:06:23 +07:00
dependabot[bot]
652d029a2d chore(deps-dev): bump @types/node from 25.0.10 to 25.1.0 in /superset-frontend (#37563)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 21:03:26 +07:00
dependabot[bot]
e67b1f5326 chore(deps-dev): bump baseline-browser-mapping from 2.9.18 to 2.9.19 in /superset-frontend (#37565)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 20:56:46 +07:00
dependabot[bot]
fa79a467e4 chore(deps): bump googleapis from 170.1.0 to 171.0.0 in /superset-frontend (#37564)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 16:57:04 +07:00
Pedro Rodrigues
2cce0308d4 fix: big number drill to details column data (#37068) 2026-01-30 12:32:49 +03:00
dependabot[bot]
c7fd1a2f65 chore(deps-dev): bump @types/node from 25.0.10 to 25.1.0 in /superset-websocket (#37539)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 10:22:41 +07:00
dependabot[bot]
ab4f646ef6 chore(deps): bump @babel/core from 7.28.5 to 7.28.6 in /docs (#37540)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 10:22:15 +07:00
Alejandro Solares
d6029f5c8a chore(deps): bump dependencies to address security vulnerabilities (#37552) 2026-01-30 10:19:43 +07:00
dependabot[bot]
c16e8f747c chore(deps-dev): bump css-loader from 7.1.2 to 7.1.3 in /superset-frontend (#37544)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 10:18:20 +07:00
435 changed files with 37592 additions and 7216 deletions

2
.github/CODEOWNERS vendored
View File

@@ -20,7 +20,7 @@
# Notify PMC members of changes to GitHub Actions
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber
# Notify PMC members of changes to required GitHub Actions

View File

@@ -33,7 +33,7 @@ jobs:
pull-requests: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

@@ -189,7 +189,7 @@ jobs:
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -225,7 +225,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

@@ -68,7 +68,7 @@ jobs:
yarn install --check-cache
- name: Download database diagnostics (if triggered by integration tests)
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
uses: dawidd6/action-download-artifact@v12
uses: dawidd6/action-download-artifact@v14
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
@@ -77,7 +77,7 @@ jobs:
path: docs/src/data/
- name: Try to download latest diagnostics (for push/dispatch triggers)
if: github.event_name != 'workflow_run'
uses: dawidd6/action-download-artifact@v12
uses: dawidd6/action-download-artifact@v14
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml

View File

@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v6
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
- uses: JustinBeckwith/linkinator-action@f62ba0c110a76effb2ee6022cc6ce4ab161085e3 # v2.4
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
with:
paths: "**/*.md, **/*.mdx"
@@ -111,7 +111,7 @@ jobs:
run: |
yarn install --check-cache
- name: Download database diagnostics from integration tests
uses: dawidd6/action-download-artifact@v12
uses: dawidd6/action-download-artifact@v14
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}

View File

@@ -52,6 +52,7 @@ jobs:
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@v5
with:

View File

@@ -27,6 +27,7 @@ repos:
args: [--check-untyped-defs]
exclude: ^superset-extensions-cli/
additional_dependencies: [
types-cachetools,
types-simplejson,
types-python-dateutil,
types-requests,

View File

@@ -430,6 +430,11 @@ categories:
url: https://brandct.cn/
contributors: ["@wenbinye"]
- name: XNET
url: https://xnetmobile.com/
logo: xnet.png
contributors: ["@deuspt"]
- name: Zeta
url: https://www.zeta.tech/
contributors: ["@shaikidris"]

View File

@@ -24,6 +24,21 @@ assists people when migrating to a new version.
## Next
### WebSocket config for GAQ with Docker
[35896](https://github.com/apache/superset/pull/35896) and [37624](https://github.com/apache/superset/pull/37624) updated documentation on how to run and configure Superset with Docker. Specifically for the WebSocket configuration, a new `docker/superset-websocket/config.example.json` was added to the repo, so that users could copy it to create a `docker/superset-websocket/config.json` file. The existing `docker/superset-websocket/config.json` was removed and git-ignored, so if you're using GAQ / WebSocket make sure to:
- Stash/backup your existing `config.json` file, to re-apply it after (will get git-ignored going forward)
- Update the `volumes` configuration for the `superset-websocket` service in your `docker-compose.override.yml` file, to include the `docker/superset-websocket/config.json` file. For example:
``` yaml
services:
superset-websocket:
volumes:
- ./superset-websocket:/home/superset-websocket
- /home/superset-websocket/node_modules
- /home/superset-websocket/dist
- ./docker/superset-websocket/config.json:/home/superset-websocket/config.json:ro
```
### Example Data Loading Improvements
#### New Directory Structure

View File

@@ -159,8 +159,8 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server configuration
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces

View File

@@ -175,7 +175,7 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset:8088"
# Bind to all interfaces so Docker port mapping works
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
WEBPACK_DEVSERVER_HOST: "0.0.0.0"
ports:
- "127.0.0.1:${NODE_PORT:-9000}:9000" # exposing the dynamic webpack dev server

View File

@@ -28,11 +28,11 @@ if [ "$BUILD_SUPERSET_FRONTEND_IN_DOCKER" = "true" ]; then
cd /app/superset-frontend
if [ "$NPM_RUN_PRUNE" = "true" ]; then
echo "Running `npm run prune`"
echo "Running \"npm run prune\""
npm run prune
fi
echo "Running `npm install`"
echo "Running \"npm install\""
npm install
echo "Start webpack dev server"

View File

@@ -105,7 +105,12 @@ class CeleryConfig:
CELERY_CONFIG = CeleryConfig
FEATURE_FLAGS = {"ALERT_REPORTS": True}
FEATURE_FLAGS = {
"ALERT_REPORTS": True,
"DATASET_FOLDERS": True,
"ENABLE_EXTENSIONS": True,
}
EXTENSIONS_PATH = "/app/docker/extensions"
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
# The base URL for the email report hyperlinks.

View File

@@ -1,22 +0,0 @@
{
"port": 8080,
"logLevel": "info",
"logToFile": false,
"logFilename": "app.log",
"statsd": {
"host": "127.0.0.1",
"port": 8125,
"globalTags": []
},
"redis": {
"port": 6379,
"host": "127.0.0.1",
"password": "",
"db": 0,
"ssl": false
},
"redisStreamPrefix": "async-events-",
"jwtAlgorithms": ["HS256"],
"jwtSecret": "CHANGE-ME-IN-PRODUCTION-GOTTA-BE-LONG-AND-SECRET",
"jwtCookieName": "async-token"
}

View File

@@ -171,9 +171,11 @@ const config: Config = {
url: 'https://superset.apache.org',
baseUrl: '/',
onBrokenLinks: 'warn',
onBrokenMarkdownLinks: 'throw',
markdown: {
mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'throw',
},
},
favicon: '/img/favicon.ico',
organizationName: 'apache',
@@ -186,14 +188,6 @@ const config: Config = {
],
plugins: [
require.resolve('./src/webpack.extend.ts'),
[
'docusaurus-plugin-less',
{
lessOptions: {
javascriptEnabled: true,
},
},
],
...dynamicPlugins,
[
'docusaurus-plugin-openapi-docs',

View File

@@ -35,7 +35,7 @@
# Yarn version
YARN_VERSION = "1.22.22"
# Increase heap size for webpack bundling of Superset UI components
NODE_OPTIONS = "--max-old-space-size=4096"
NODE_OPTIONS = "--max-old-space-size=8192"
# Deploy preview settings
[context.deploy-preview]

View File

@@ -6,9 +6,10 @@
"scripts": {
"docusaurus": "docusaurus",
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
"start": "yarn run _init && yarn run generate:all && NODE_ENV=development docusaurus start",
"start": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start",
"start:quick": "yarn run _init && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start",
"stop": "pkill -f 'docusaurus start' || pkill -f 'docusaurus serve' || echo 'No docusaurus server running'",
"build": "yarn run _init && yarn run generate:all && DEBUG=docusaurus:* docusaurus build",
"build": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' DEBUG=docusaurus:* docusaurus build",
"generate:api-docs": "python3 scripts/fix-openapi-spec.py && docusaurus gen-api-docs superset && node scripts/convert-api-sidebar.mjs && node scripts/generate-api-index.mjs && node scripts/generate-api-tag-pages.mjs",
"clean:api-docs": "docusaurus clean-api-docs superset",
"swizzle": "docusaurus swizzle",
@@ -22,7 +23,7 @@
"generate:superset-components": "node scripts/generate-superset-components.mjs",
"generate:database-docs": "node scripts/generate-database-docs.mjs",
"gen-db-docs": "node scripts/generate-database-docs.mjs",
"generate:all": "yarn run generate:extension-components && yarn run generate:superset-components && yarn run generate:database-docs && yarn run generate:api-docs",
"generate:all": "yarn run generate:extension-components & yarn run generate:superset-components & yarn run generate:database-docs & wait && yarn run generate:api-docs",
"lint:db-metadata": "python3 ../superset/db_engine_specs/lint_metadata.py",
"lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md",
"update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme",
@@ -38,15 +39,11 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@babel/core": "^7.26.0",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@docusaurus/core": "3.9.2",
"@docusaurus/plugin-client-redirects": "3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@docusaurus/theme-live-codeblock": "^3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/core": "^11.0.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.14.1",
@@ -55,41 +52,39 @@
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^8.6.15",
"@storybook/blocks": "^8.6.11",
"@storybook/channels": "^8.6.11",
"@storybook/client-logger": "^8.6.11",
"@storybook/components": "^8.6.11",
"@storybook/core": "^8.6.11",
"@storybook/core-events": "^8.6.11",
"@storybook/blocks": "^8.6.15",
"@storybook/channels": "^8.6.15",
"@storybook/client-logger": "^8.6.15",
"@storybook/components": "^8.6.15",
"@storybook/core": "^8.6.15",
"@storybook/core-events": "^8.6.15",
"@storybook/csf": "^0.1.13",
"@storybook/docs-tools": "^8.6.11",
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@storybook/docs-tools": "^8.6.15",
"@storybook/preview-api": "^8.6.15",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"antd": "^6.2.2",
"babel-loader": "^9.2.1",
"caniuse-lite": "^1.0.30001766",
"docusaurus-plugin-less": "^2.0.2",
"@swc/core": "^1.15.11",
"antd": "^6.2.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001769",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",
"js-yaml-loader": "^1.2.2",
"json-bigint": "^1.0.0",
"less": "^4.5.1",
"less-loader": "^12.3.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-github-btn": "^1.4.0",
"react-resize-detector": "7.1.2",
"react-resize-detector": "^9.1.1",
"react-svg-pan-zoom": "^3.13.1",
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.1.1",
"storybook": "^8.6.15",
"swagger-ui-react": "^5.31.0",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"ts-loader": "^9.5.4",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
@@ -104,11 +99,11 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.2.0",
"globals": "^17.3.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0",
"webpack": "^5.104.1"
"webpack": "^5.105.0"
},
"browserslist": {
"production": [
@@ -124,7 +119,8 @@
},
"resolutions": {
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.5.0"
"@reduxjs/toolkit": "^2.5.0",
"baseline-browser-mapping": "^2.9.19"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}

View File

@@ -1,16 +1,16 @@
{
"generated": "2026-01-27T23:17:43.310Z",
"generated": "2026-01-31T10:47:01.730Z",
"statistics": {
"totalDatabases": 68,
"withDocumentation": 68,
"withConnectionString": 68,
"withDrivers": 35,
"totalDatabases": 70,
"withDocumentation": 70,
"withConnectionString": 70,
"withDrivers": 36,
"withAuthMethods": 4,
"supportsJoins": 64,
"supportsSubqueries": 65,
"supportsJoins": 66,
"supportsSubqueries": 67,
"supportsDynamicSchema": 15,
"supportsCatalog": 9,
"averageScore": 33,
"averageScore": 32,
"maxScore": 201,
"byCategory": {
"Other Databases": [
@@ -109,6 +109,8 @@
"Traditional RDBMS": [
"Aurora MySQL (Data API)",
"Aurora PostgreSQL (Data API)",
"Aurora MySQL",
"Aurora PostgreSQL",
"CockroachDB",
"Cloudflare D1",
"IBM Db2",
@@ -133,6 +135,8 @@
"Open Source": [
"Aurora MySQL (Data API)",
"Aurora PostgreSQL (Data API)",
"Aurora MySQL",
"Aurora PostgreSQL",
"ClickHouse",
"CockroachDB",
"Couchbase",
@@ -490,6 +494,132 @@
"query_cost_estimation": false,
"sql_validation": false
},
"Aurora MySQL": {
"engine": "aurora_mysql",
"engine_name": "Aurora MySQL",
"module": "aurora",
"documentation": {
"description": "MySQL is a popular open-source relational database.",
"logo": "mysql.png",
"homepage_url": "https://www.mysql.com/",
"categories": [
"TRADITIONAL_RDBMS",
"OPEN_SOURCE"
],
"pypi_packages": [
"mysqlclient"
],
"connection_string": "mysql://{username}:{password}@{host}/{database}",
"default_port": 3306,
"parameters": {
"username": "Database username",
"password": "Database password",
"host": "localhost, 127.0.0.1, IP address, or hostname",
"database": "Database name"
},
"host_examples": [
{
"platform": "Localhost",
"host": "localhost or 127.0.0.1"
},
{
"platform": "Docker on Linux",
"host": "172.18.0.1"
},
{
"platform": "Docker on macOS",
"host": "docker.for.mac.host.internal"
},
{
"platform": "On-premise",
"host": "IP address or hostname"
}
],
"drivers": [
{
"name": "mysqlclient",
"pypi_package": "mysqlclient",
"connection_string": "mysql://{username}:{password}@{host}/{database}",
"is_recommended": true,
"notes": "Recommended driver. May fail with caching_sha2_password auth."
},
{
"name": "mysql-connector-python",
"pypi_package": "mysql-connector-python",
"connection_string": "mysql+mysqlconnector://{username}:{password}@{host}/{database}",
"is_recommended": false,
"notes": "Required for newer MySQL databases using caching_sha2_password authentication."
}
]
},
"time_grains": {},
"score": 0,
"max_score": 0,
"joins": true,
"subqueries": true,
"supports_dynamic_schema": false,
"supports_catalog": false,
"supports_dynamic_catalog": false,
"ssh_tunneling": false,
"query_cancelation": false,
"supports_file_upload": false,
"user_impersonation": false,
"query_cost_estimation": false,
"sql_validation": false
},
"Aurora PostgreSQL": {
"engine": "aurora_postgresql",
"engine_name": "Aurora PostgreSQL",
"module": "aurora",
"documentation": {
"description": "PostgreSQL is an advanced open-source relational database.",
"logo": "postgresql.svg",
"homepage_url": "https://www.postgresql.org/",
"categories": [
"TRADITIONAL_RDBMS",
"OPEN_SOURCE"
],
"pypi_packages": [
"psycopg2"
],
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
"default_port": 5432,
"parameters": {
"username": "Database username",
"password": "Database password",
"host": "For localhost: localhost or 127.0.0.1. For AWS: endpoint URL",
"port": "Default 5432",
"database": "Database name"
},
"notes": "The psycopg2 library comes bundled with Superset Docker images.",
"connection_examples": [
{
"description": "Basic connection",
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}"
},
{
"description": "With SSL required",
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}?sslmode=require"
}
],
"docs_url": "https://www.postgresql.org/docs/",
"sqlalchemy_docs_url": "https://docs.sqlalchemy.org/en/13/dialects/postgresql.html"
},
"time_grains": {},
"score": 0,
"max_score": 0,
"joins": true,
"subqueries": true,
"supports_dynamic_schema": false,
"supports_catalog": false,
"supports_dynamic_catalog": false,
"ssh_tunneling": false,
"query_cancelation": false,
"supports_file_upload": false,
"user_impersonation": false,
"query_cost_estimation": false,
"sql_validation": false
},
"Google BigQuery": {
"engine": "google_bigquery",
"engine_name": "Google BigQuery",

View File

@@ -28,7 +28,7 @@ import databaseData from '../data/databases.json';
import BlurredSection from '../components/BlurredSection';
import DataSet from '../../../RESOURCES/INTHEWILD.yaml';
import type { DatabaseData } from '../components/databases/types';
import '../styles/main.less';
import '../styles/main.css';
// Build database list from databases.json (databases with logos)
// Deduplicate by logo filename to avoid showing the same logo twice
@@ -795,7 +795,7 @@ export default function Home(): JSX.Element {
</StyledIntegrations>
</BlurredSection>
{/* Only show carousel when we have enough logos (>10) for a good display */}
{companiesWithLogos.length > 10 && (
{companiesWithLogos.length > 7 && (
<BlurredSection>
<div style={{ padding: '0 20px' }}>
<SectionHeader

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
@import 'antd-theme.less';
body {
font-family: var(--ifm-font-family-base);
@@ -81,26 +80,29 @@ a > span > svg {
text-align: center;
position: relative;
z-index: 2;
&::before {
border-radius: inherit;
background: linear-gradient(180deg, #11b0d8 0%, #116f86 100%);
content: '';
display: block;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
width: 100%;
z-index: -1;
transition: all 0.3s;
}
&:hover {
color: #ffffff;
&::before {
opacity: 1;
}
}
}
.default-button-theme::before {
border-radius: inherit;
background: linear-gradient(180deg, #11b0d8 0%, #116f86 100%);
content: '';
display: block;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
width: 100%;
z-index: -1;
transition: all 0.3s;
}
.default-button-theme:hover {
color: #ffffff;
}
.default-button-theme:hover::before {
opacity: 1;
}
/* Navbar */
@@ -109,32 +111,32 @@ a > span > svg {
font-size: 14px;
font-weight: 400;
transition: all 0.5s;
}
.get-started-button {
border-radius: 10px;
font-size: 18px;
font-weight: bold;
width: 142px;
padding: 7px 0;
margin-right: 20px;
}
.navbar .get-started-button {
border-radius: 10px;
font-size: 18px;
font-weight: bold;
width: 142px;
padding: 7px 0;
margin-right: 20px;
}
.github-button {
background-image: url('/img/github.png');
background-size: contain;
width: 30px;
height: 30px;
margin-right: 10px;
}
.navbar .github-button {
background-image: url('/img/github.png');
background-size: contain;
width: 30px;
height: 30px;
margin-right: 10px;
}
.navbar--dark {
background-color: transparent;
border-bottom: 1px solid rgba(24, 115, 132, 0.4);
}
.github-button {
background-image: url('/img/github-dark.png');
}
.navbar--dark .github-button {
background-image: url('/img/github-dark.png');
}
.navbar__logo {
@@ -153,11 +155,11 @@ a > span > svg {
.navbar {
padding-right: 8px;
padding-left: 8px;
}
.get-started-button,
.github-button {
display: none;
}
.navbar .get-started-button,
.navbar .github-button {
display: none;
}
.navbar__items {
@@ -186,20 +188,20 @@ a > span > svg {
--docsearch-searchbox-background: var(--ifm-navbar-background-color);
border: 1px solid #187384;
border-radius: 10px;
}
&.DocSearch-Button {
width: 225px;
}
.navbar .DocSearch.DocSearch-Button {
width: 225px;
}
.DocSearch-Search-Icon {
width: 16px;
height: 16px;
}
.navbar .DocSearch .DocSearch-Search-Icon {
width: 16px;
height: 16px;
}
.DocSearch-Button-Key,
.DocSearch-Button-Placeholder {
display: none;
}
.navbar .DocSearch .DocSearch-Button-Key,
.navbar .DocSearch .DocSearch-Button-Placeholder {
display: none;
}
.navbar--dark .DocSearch {
@@ -232,25 +234,25 @@ a > span > svg {
align-items: center;
justify-content: center;
gap: 16px;
}
span {
font-size: 13px;
opacity: 0.85;
}
.footer__ci-services span {
font-size: 13px;
opacity: 0.85;
}
a {
display: inline-flex;
align-items: center;
transition: opacity 0.2s;
.footer__ci-services a {
display: inline-flex;
align-items: center;
transition: opacity 0.2s;
}
&:hover {
opacity: 0.8;
}
}
.footer__ci-services a:hover {
opacity: 0.8;
}
img {
height: 28px;
}
.footer__ci-services img {
height: 28px;
}
.footer__divider {
@@ -268,13 +270,13 @@ a > span > svg {
.footer__ci-services {
gap: 12px;
padding: 10px 16px;
}
span {
font-size: 12px;
}
.footer__ci-services span {
font-size: 12px;
}
img {
height: 22px;
}
.footer__ci-services img {
height: 22px;
}
}

View File

@@ -67,8 +67,8 @@ export default function webpackExtendPlugin(): Plugin<void> {
use: 'js-yaml-loader',
});
// Add babel-loader rule for superset-frontend files
// This ensures Emotion CSS-in-JS is processed correctly for SSG
// Add swc-loader rule for superset-frontend files
// SWC is a Rust-based transpiler that's significantly faster than babel
const supersetFrontendPath = path.resolve(
__dirname,
'../../superset-frontend',
@@ -76,26 +76,37 @@ export default function webpackExtendPlugin(): Plugin<void> {
config.module?.rules?.push({
test: /\.(tsx?|jsx?)$/,
include: supersetFrontendPath,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
loader: 'swc-loader',
options: {
presets: [
[
'@babel/preset-react',
{
// Ignore superset-frontend/.swcrc which references plugins not
// installed in the docs workspace (e.g. @swc/plugin-emotion)
swcrc: false,
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
importSource: '@emotion/react',
},
],
'@babel/preset-typescript',
],
plugins: ['@emotion/babel-plugin'],
},
},
},
},
});
return {
devtool: isDev ? 'eval-source-map' : config.devtool,
devtool: isDev ? false : config.devtool,
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
...(isDev && {
optimization: {
...config.optimization,
@@ -208,8 +219,6 @@ export default function webpackExtendPlugin(): Plugin<void> {
),
},
},
// We're removing the ts-loader rule that was processing superset-frontend files
// This will prevent TypeScript errors from files outside the docs directory
};
},
};

View File

@@ -114,6 +114,12 @@
"lifecycle": "testing",
"description": "Allow users to export full CSV of table viz type. Warning: Could cause server memory/compute issues with large datasets."
},
{
"name": "AWS_DATABASE_IAM_AUTH",
"default": false,
"lifecycle": "testing",
"description": "Enable AWS IAM authentication for database connections (Aurora, Redshift). Allows cross-account role assumption via STS AssumeRole. Security note: When enabled, ensure Superset's IAM role has restricted sts:AssumeRole permissions to prevent unauthorized access."
},
{
"name": "CACHE_IMPERSONATION",
"default": false,
@@ -241,6 +247,13 @@
"description": "Enables dashboard virtualization for improved performance",
"category": "path_to_deprecation"
},
{
"name": "DASHBOARD_VIRTUALIZATION_DEFER_DATA",
"default": false,
"lifecycle": "stable",
"description": "Supports simultaneous data and dashboard virtualization for backend performance",
"category": "runtime_config"
},
{
"name": "DATAPANEL_CLOSED_BY_DEFAULT",
"default": false,

BIN
docs/static/img/databases/alloydb.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="Õ_xBA__x2264__x201E__1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 107.7 107.7"
style="enable-background:new 0 0 107.7 107.7;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#9E2878;}
</style>
<g>
<g id="g1133" transform="translate(-244.51235,-228.78793)">
<path id="path1119" class="st0" d="M340.8,253.8c2.6-1,5.5,0.4,6.2,3c0.7,2.6-1.1,5.7-3.7,6.4c-3.2,0.6-6.2-1.4-9.3,0.2
c-3.1,1.7-4,5.2-4.7,8.3c-1.4,5-8.5,7.3-12.1,4.2c-3.3-2.4-3.4-7.8-0.2-11c2.2-2.5,5.9-3.3,8.8-2c2.7,1.2,6,1.7,8.6-0.4
C337.5,260.1,336.7,255.1,340.8,253.8L340.8,253.8z"/>
<path id="path1121" class="st0" d="M280.5,244.7c4.2-2.2,9.5,1.5,8.2,6.1c-1.4,5.4-0.7,11.5,2.9,15.5c3.4,4,9.8,4.8,14.6,1.9
c3.7-2.1,6-5.8,7.4-9.6c1-3.1,0.6-6.2,1.1-9.3c1-3.8,5.8-6,9.1-4.2c3.2,1.4,4,5.9,1.7,8.8c-1.4,2.2-4.2,2.7-6.3,3.8
c-3.6,1.9-6.5,4.9-8.4,8.6c-1.2,2.1-1.1,4.5-1.7,6.7c-0.9,1.9-3,2.7-4.8,2.9c-4.8,0.6-9.5,3-13,6.7c-1.7,1.7-3.1,4.2-5.6,4.2
c-2.7-0.2-4.7-2.6-7.4-2.7c-4.9-0.7-10.2,0.7-14.4,4c-3.7,3.1-9.4,0.8-9.6-3.7c-0.9-5.1,6.2-9.5,10.1-6.2c4.3,4,10.8,5.6,16.9,3.7
c5.6-1.9,9.7-8.3,8.6-14c-0.9-4.9-4.2-9.3-8.6-11.4c-1.7-0.8-3.6-1.6-4.2-3.5C275.6,250.2,277.3,246.2,280.5,244.7L280.5,244.7z"
/>
<path id="path1123" class="st0" d="M277.8,235.9c2.2-0.8,4.2,1.3,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1.1
C275,238.9,276,236.4,277.8,235.9z"/>
<path id="path1125" class="st0" d="M246.9,278.8c2.2-0.8,4.2,1.2,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1C244,281.9,245,279.3,246.9,278.8
L246.9,278.8z"/>
<path id="path1127" class="st0" d="M328.2,236.2c2.2-0.7,4.2,1.3,3.3,3.5c-0.6,2-3.7,2.7-4.8,1
C325.4,239.2,326.3,236.8,328.2,236.2z"/>
<path id="path1129" class="st0" d="M253.6,257.7c0.4-3.7,5.5-5.9,8.1-3.6c1.9,1.1,1.6,3.6,2.2,5.4c0.4,2.4,2.7,4.3,5.2,4.3
c3.2,0.3,6.4-2.3,9.5-1.2c4.8,1.2,6.5,7.5,3.2,11.5c-2.9,4.1-9.3,4.5-12,0.7c-2.4-2.8-0.5-7.1-2.7-10.1c-1.7-2.9-5.4-2.7-8.3-1.9
C255.8,263.8,253,260.7,253.6,257.7L253.6,257.7z"/>
<path id="path1131" class="st0" d="M300.8,230c3.3-1.9,7.5,0.9,6.7,4.6c0,2.3-2.2,3.6-3.5,5.2c-1.9,1.9-2.3,4.9-1.2,7
c1.3,2.9,4.9,4,5.5,7.2c1.2,4.8-3.3,10.1-8.2,9.8c-4.8,0.1-8.3-5-6.3-9.5c1.2-3.8,5.8-4.9,7.2-8.5c1.7-3.2-0.4-6.1-2.4-8.1
C296.7,235.6,297.9,231.4,300.8,230z"/>
</g>
<g id="g1149" transform="translate(-244.51235,-228.78793)">
<path id="path1135" class="st0" d="M256,311.5c-2.6,1-5.5-0.4-6.2-3c-0.7-2.6,1.1-5.7,3.7-6.4c3.2-0.6,6.2,1.4,9.3-0.2
c3.1-1.6,4-5.1,4.7-8.2c1.4-5,8.5-7.3,12.1-4.2c3.3,2.4,3.4,7.8,0.2,10.9c-2.2,2.5-5.9,3.3-8.8,2c-2.7-1.2-6-1.7-8.6,0.4
C259.1,305,259.9,310.2,256,311.5L256,311.5z"/>
<path id="path1137" class="st0" d="M316.1,320.5c-4.2,2.2-9.5-1.5-8.2-6c1.4-5.4,0.7-11.6-2.9-15.6c-3.4-4-9.8-4.7-14.6-1.9
c-3.7,2-6,5.7-7.4,9.5c-1,3.1-0.6,6.2-1.1,9.3c-1,3.8-5.8,6-9.1,4.3c-3.2-1.4-4-5.9-1.7-8.9c1.4-2.2,4.2-2.7,6.3-3.8
c3.6-1.9,6.5-4.9,8.4-8.6c1.2-2,1.1-4.5,1.7-6.6c0.9-1.9,3-2.7,4.8-2.9c4.8-0.6,9.5-3,13-6.7c1.7-1.6,3.1-4.2,5.6-4.1
c2.7,0.1,4.7,2.5,7.4,2.7c4.9,0.6,10.2-0.8,14.4-4c3.7-3.2,9.4-0.9,9.6,3.6c0.9,5.1-6.2,9.5-10.1,6.2c-4.3-4-10.8-5.6-16.9-3.7
c-5.6,1.9-9.7,8.3-8.6,14c0.9,4.8,4.2,9.3,8.6,11.3c1.7,0.8,3.6,1.6,4.2,3.5C321.2,314.9,319.4,319.1,316.1,320.5L316.1,320.5z"/>
<path id="path1139" class="st0" d="M318.9,329.4c-2.2,0.8-4.2-1.3-3.3-3.4c0.6-2.1,3.7-2.7,4.8-1.1
C321.7,326.3,320.7,328.8,318.9,329.4z"/>
<path id="path1141" class="st0" d="M349.9,286.5c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2.1,3.7-2.7,4.8-1
C352.8,283.5,351.8,285.9,349.9,286.5z"/>
<path id="path1143" class="st0" d="M268.5,329c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2,3.7-2.7,4.8-1C271.3,325.9,270.3,328.5,268.5,329z"
/>
<path id="path1145" class="st0" d="M343.1,307.4c-0.4,3.7-5.5,5.9-8.1,3.6c-1.9-1.1-1.6-3.6-2.2-5.4c-0.4-2.4-2.7-4.3-5.2-4.3
c-3.2-0.3-6.4,2.3-9.5,1.2c-4.8-1.2-6.5-7.5-3.2-11.5c2.9-4.1,9.3-4.5,12-0.7c2.4,2.8,0.5,7,2.7,10c1.7,2.9,5.4,2.7,8.3,1.9
C341,301.4,343.8,304.4,343.1,307.4L343.1,307.4z"/>
<path id="path1147" class="st0" d="M295.8,335.2c-3.3,2-7.5-0.9-6.7-4.6c0-2.3,2.2-3.6,3.5-5.2c1.9-1.9,2.3-4.9,1.2-7
c-1.3-2.9-4.9-4-5.5-7.2c-1.2-4.8,3.3-10.1,8.2-9.8c4.8-0.1,8.3,5,6.3,9.6c-1.2,3.7-5.8,4.8-7.2,8.4c-1.7,3.2,0.4,6.1,2.4,8.2
C300,329.6,298.8,333.8,295.8,335.2L295.8,335.2z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/static/img/databases/neon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
docs/static/img/logos/xnet.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -174,7 +174,7 @@ oracle = ["cx-Oracle>8.0.0, <8.1"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <6.0.0"]
playwright = ["playwright>=1.37.0, <2"]
postgres = ["psycopg2-binary==2.9.6"]
postgres = ["psycopg2-binary==2.9.9"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.328.0"]
prophet = ["prophet>=1.1.6, <2"]
@@ -204,6 +204,7 @@ ydb = ["ydb-sqlalchemy>=0.1.2"]
development = [
# no bounds for apache-superset-extensions-cli until a stable version
"apache-superset-extensions-cli",
"boto3",
"docker",
"flask-testing",
"freezegun",
@@ -437,6 +438,7 @@ authorized_licenses = [
"apache software",
"apache software, bsd",
"bsd",
"bsd-2-clause",
"bsd-3-clause",
"isc license (iscl)",
"isc license",

View File

@@ -16,8 +16,14 @@
# specific language governing permissions and limitations
# under the License.
#
urllib3>=2.6.0,<3.0.0
werkzeug>=3.0.1
# Security: CVE-2026-21441 - decompression bomb bypass on redirects
urllib3>=2.6.3,<3.0.0
# Security: GHSA-87hc-h4r5-73f7 - Windows path traversal fix
werkzeug>=3.1.5,<4.0.0
# Security: CVE-2025-68146 - TOCTOU symlink vulnerability
filelock>=3.20.3,<4.0.0
# Security: decompression bomb fix (required by aiohttp 3.13.3)
brotli>=1.2.0,<2.0.0
numexpr>=2.9.0
# 5.0.0 has a sensitive deprecation used in other libs

View File

@@ -36,8 +36,10 @@ blinker==1.9.0
# via flask
bottleneck==1.5.0
# via apache-superset (pyproject.toml)
brotli==1.1.0
# via flask-compress
brotli==1.2.0
# via
# -r requirements/base.in
# flask-compress
cachelib==0.13.0
# via
# flask-caching
@@ -101,6 +103,8 @@ email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
filelock==3.20.3
# via -r requirements/base.in
flask==2.3.3
# via
# apache-superset (pyproject.toml)
@@ -289,7 +293,7 @@ prompt-toolkit==3.0.51
# via click-repl
pyarrow==16.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.1
pyasn1==0.6.2
# via
# pyasn1-modules
# rsa
@@ -436,7 +440,7 @@ tzdata==2025.2
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.6.0
urllib3==2.6.3
# via
# -r requirements/base.in
# requests
@@ -453,7 +457,7 @@ wcwidth==0.2.13
# via prompt-toolkit
websocket-client==1.8.0
# via selenium
werkzeug==3.1.3
werkzeug==3.1.5
# via
# -r requirements/base.in
# flask

View File

@@ -76,11 +76,17 @@ blinker==1.9.0
# via
# -c requirements/base-constraint.txt
# flask
boto3==1.42.39
# via apache-superset
botocore==1.42.39
# via
# boto3
# s3transfer
bottleneck==1.5.0
# via
# -c requirements/base-constraint.txt
# apache-superset
brotli==1.1.0
brotli==1.2.0
# via
# -c requirements/base-constraint.txt
# flask-compress
@@ -235,8 +241,10 @@ fakeredis==2.32.1
# via pydocket
fastmcp==2.14.3
# via apache-superset
filelock==3.12.2
# via virtualenv
filelock==3.20.3
# via
# -c requirements/base-constraint.txt
# virtualenv
flask==2.3.3
# via
# -c requirements/base-constraint.txt
@@ -458,6 +466,10 @@ jinja2==3.1.6
# apache-superset-extensions-cli
# flask
# flask-babel
jmespath==1.1.0
# via
# boto3
# botocore
jsonpath-ng==1.7.0
# via
# -c requirements/base-constraint.txt
@@ -700,7 +712,7 @@ protobuf==4.25.5
# proto-plus
psutil==6.1.0
# via apache-superset
psycopg2-binary==2.9.6
psycopg2-binary==2.9.9
# via apache-superset
py-key-value-aio==0.3.0
# via
@@ -714,7 +726,7 @@ pyarrow==16.1.0
# apache-superset
# db-dtypes
# pandas-gbq
pyasn1==0.6.1
pyasn1==0.6.2
# via
# -c requirements/base-constraint.txt
# pyasn1-modules
@@ -810,6 +822,7 @@ python-dateutil==2.9.0.post0
# via
# -c requirements/base-constraint.txt
# apache-superset
# botocore
# celery
# croniter
# flask-appbuilder
@@ -913,6 +926,8 @@ rsa==4.9.1
# google-auth
ruff==0.9.7
# via apache-superset
s3transfer==0.16.0
# via boto3
secretstorage==3.5.0
# via keyring
selenium==4.32.0
@@ -1061,9 +1076,10 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.6.0
urllib3==2.6.3
# via
# -c requirements/base-constraint.txt
# botocore
# docker
# requests
# requests-cache
@@ -1095,7 +1111,7 @@ websocket-client==1.8.0
# selenium
websockets==15.0.1
# via fastmcp
werkzeug==3.1.3
werkzeug==3.1.5
# via
# -c requirements/base-constraint.txt
# flask

View File

@@ -0,0 +1,114 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable, TypeVar
from pydantic import BaseModel
from superset_core.semantic_layers.semantic_view import SemanticView
ConfigT = TypeVar("ConfigT", bound=BaseModel, contravariant=True)
SemanticViewT = TypeVar("SemanticViewT", bound="SemanticView")
# TODO (betodealmeida): convert to ABC
@runtime_checkable
class SemanticLayer(Protocol[ConfigT, SemanticViewT]):
"""
A protocol for semantic layers.
"""
@classmethod
def from_configuration(
cls,
configuration: dict[str, Any],
) -> SemanticLayer[ConfigT, SemanticViewT]:
"""
Create a semantic layer from its configuration.
"""
@classmethod
def get_configuration_schema(
cls,
configuration: ConfigT | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the configuration needed to add the semantic layer.
A partial configuration `configuration` can be sent to improve the schema,
allowing for progressive validation and better UX. For example, a semantic
layer might require:
- auth information
- a database
If the user provides the auth information, a client can send the partial
configuration to this method, and the resulting JSON schema would include
the list of databases the user has access to, allowing a dropdown to be
populated.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are populated based on the provided connection info.
"""
@classmethod
def get_runtime_schema(
cls,
configuration: ConfigT,
runtime_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the runtime parameters needed to load semantic views.
This returns the schema needed to connect to a semantic view given the
configuration for the semantic layer. For example, a semantic layer might
be configured by:
- auth information
- an optional database
If the user does not provide a database when creating the semantic layer, the
runtime schema would require the database name to be provided before loading any
semantic views. This allows users to create semantic layers that connect to a
specific database (or project, account, etc.), or that allow users to select it
at query time.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are required if they were not provided in the initial
configuration.
"""
def get_semantic_views(
self,
runtime_configuration: dict[str, Any],
) -> set[SemanticViewT]:
"""
Get the semantic views available in the semantic layer.
The runtime configuration can provide information like a given project or
schema, used to restrict the semantic views returned.
"""
def get_semantic_view(
self,
name: str,
additional_configuration: dict[str, Any],
) -> SemanticViewT:
"""
Get a specific semantic view by its name and additional configuration.
"""

View File

@@ -0,0 +1,105 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import enum
from typing import Protocol, runtime_checkable
from superset_core.semantic_layers.types import (
Dimension,
Filter,
GroupLimit,
Metric,
OrderTuple,
SemanticResult,
)
# TODO (betodealmeida): move to the extension JSON
class SemanticViewFeature(enum.Enum):
"""
Custom features supported by semantic layers.
"""
ADHOC_EXPRESSIONS_IN_ORDERBY = "ADHOC_EXPRESSIONS_IN_ORDERBY"
GROUP_LIMIT = "GROUP_LIMIT"
GROUP_OTHERS = "GROUP_OTHERS"
# TODO (betodealmeida): convert to ABC
@runtime_checkable
class SemanticView(Protocol):
"""
A protocol for semantic views.
"""
features: frozenset[SemanticViewFeature]
def uid(self) -> str:
"""
Returns a unique identifier for the semantic view.
"""
def get_dimensions(self) -> set[Dimension]:
"""
Get the dimensions defined in the semantic view.
"""
def get_metrics(self) -> set[Metric]:
"""
Get the metrics defined in the semantic view.
"""
def get_values(
self,
dimension: Dimension,
filters: set[Filter] | None = None,
) -> SemanticResult:
"""
Return distinct values for a dimension.
"""
def get_dataframe(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a semantic query and return the results as a DataFrame.
"""
def get_row_count(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a query and return the number of rows the result would have.
"""

View File

@@ -0,0 +1,328 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import enum
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from functools import total_ordering
from typing import Type as TypeOf
from pandas import DataFrame
__all__ = [
"BINARY",
"BOOLEAN",
"DATE",
"DATETIME",
"DECIMAL",
"Day",
"Dimension",
"Hour",
"INTEGER",
"INTERVAL",
"Minute",
"Month",
"NUMBER",
"OBJECT",
"Quarter",
"Second",
"STRING",
"TIME",
"Week",
"Year",
]
class Type:
"""
Base class for types.
"""
class INTEGER(Type):
"""
Represents an integer type.
"""
class NUMBER(Type):
"""
Represents a number type.
"""
class DECIMAL(Type):
"""
Represents a decimal type.
"""
class STRING(Type):
"""
Represents a string type.
"""
class BOOLEAN(Type):
"""
Represents a boolean type.
"""
class DATE(Type):
"""
Represents a date type.
"""
class TIME(Type):
"""
Represents a time type.
"""
class DATETIME(DATE, TIME):
"""
Represents a datetime type.
"""
class INTERVAL(Type):
"""
Represents an interval type.
"""
class OBJECT(Type):
"""
Represents an object type.
"""
class BINARY(Type):
"""
Represents a binary type.
"""
@dataclass(frozen=True)
@total_ordering
class Grain:
"""
Base class for time and date grains with comparison support.
Attributes:
name: Human-readable name of the grain (e.g., "Second")
representation: ISO 8601 representation (e.g., "PT1S")
value: Time period as a timedelta
"""
name: str
representation: str
value: timedelta
def __eq__(self, other: object) -> bool:
if isinstance(other, Grain):
return self.value == other.value
return NotImplemented
def __lt__(self, other: object) -> bool:
if isinstance(other, Grain):
return self.value < other.value
return NotImplemented
def __hash__(self) -> int:
return hash((self.name, self.representation, self.value))
class Second(Grain):
name = "Second"
representation = "PT1S"
value = timedelta(seconds=1)
class Minute(Grain):
name = "Minute"
representation = "PT1M"
value = timedelta(minutes=1)
class Hour(Grain):
name = "Hour"
representation = "PT1H"
value = timedelta(hours=1)
class Day(Grain):
name = "Day"
representation = "P1D"
value = timedelta(days=1)
class Week(Grain):
name = "Week"
representation = "P1W"
value = timedelta(weeks=1)
class Month(Grain):
name = "Month"
representation = "P1M"
value = timedelta(days=30)
class Quarter(Grain):
name = "Quarter"
representation = "P3M"
value = timedelta(days=90)
class Year(Grain):
name = "Year"
representation = "P1Y"
value = timedelta(days=365)
@dataclass(frozen=True)
class Dimension:
id: str
name: str
type: TypeOf[Type]
definition: str | None = None
description: str | None = None
grain: TypeOf[Grain] | None = None
@dataclass(frozen=True)
class Metric:
id: str
name: str
type: TypeOf[Type]
definition: str
description: str | None = None
@dataclass(frozen=True)
class AdhocExpression:
id: str
definition: str
class Operator(str, enum.Enum):
EQUALS = "="
NOT_EQUALS = "!="
GREATER_THAN = ">"
LESS_THAN = "<"
GREATER_THAN_OR_EQUAL = ">="
LESS_THAN_OR_EQUAL = "<="
IN = "IN"
NOT_IN = "NOT IN"
LIKE = "LIKE"
NOT_LIKE = "NOT LIKE"
IS_NULL = "IS NULL"
IS_NOT_NULL = "IS NOT NULL"
ADHOC = "ADHOC"
FilterValues = str | int | float | bool | datetime | date | time | timedelta | None
class PredicateType(enum.Enum):
WHERE = "WHERE"
HAVING = "HAVING"
@dataclass(frozen=True, order=True)
class Filter:
type: PredicateType
column: Dimension | Metric | None
operator: Operator
value: FilterValues | frozenset[FilterValues]
class OrderDirection(enum.Enum):
ASC = "ASC"
DESC = "DESC"
OrderTuple = tuple[Metric | Dimension | AdhocExpression, OrderDirection]
@dataclass(frozen=True)
class GroupLimit:
"""
Limit query to top/bottom N combinations of specified dimensions.
The `filters` parameter allows specifying separate filter constraints for the
group limit subquery. This is useful when you want to determine the top N groups
using different criteria (e.g., a different time range) than the main query.
For example, you might want to find the top 10 products by sales over the last
30 days, but then show daily sales for those products over the last 7 days.
"""
dimensions: list[Dimension]
top: int
metric: Metric | None
direction: OrderDirection = OrderDirection.DESC
group_others: bool = False
filters: set[Filter] | None = None
@dataclass(frozen=True)
class SemanticRequest:
"""
Represents a request made to obtain semantic results.
This could be a SQL query, an HTTP request, etc.
"""
type: str
definition: str
@dataclass(frozen=True)
class SemanticResult:
"""
Represents the results of a semantic query.
This includes any requests (SQL queries, HTTP requests) that were performed in order
to obtain the results, in order to help troubleshooting.
"""
requests: list[SemanticRequest]
# TODO (betodealmeida): convert to PyArrow Table
results: DataFrame
@dataclass(frozen=True)
class SemanticQuery:
"""
Represents a semantic query.
"""
metrics: list[Metric]
dimensions: list[Dimension]
filters: set[Filter] | None = None
order: list[OrderTuple] | None = None
limit: int | None = None
offset: int | None = None
group_limit: GroupLimit | None = None

File diff suppressed because it is too large Load Diff

View File

@@ -54,7 +54,6 @@ module.exports = {
['@babel/plugin-transform-runtime', { corejs: 3 }],
// only used in packages/superset-ui-core/src/chart/components/reactify.tsx
['babel-plugin-typescript-to-proptypes', { loose: true }],
'react-hot-loader/babel',
[
'@emotion/babel-plugin',
{

View File

@@ -59,7 +59,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
],
preset: 'ts-jest',
transform: {

File diff suppressed because it is too large Load Diff

View File

@@ -161,7 +161,7 @@
"geostyler-openlayers-parser": "^4.3.0",
"geostyler-style": "7.5.0",
"geostyler-wfs-parser": "^2.0.3",
"googleapis": "^170.1.0",
"googleapis": "^171.4.0",
"immer": "^11.1.3",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -171,7 +171,7 @@
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.23",
"mapbox-gl": "^3.18.1",
"markdown-to-jsx": "^9.6.1",
"markdown-to-jsx": "^9.7.3",
"match-sorter": "^6.3.4",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -179,22 +179,22 @@
"nanoid": "^5.1.6",
"ol": "^7.5.2",
"prop-types": "^15.8.1",
"query-string": "6.14.1",
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^17.0.2",
"react-arborist": "^3.4.3",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^3.4.0",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^17.0.2",
"react-google-recaptcha": "^3.1.0",
"react-hot-loader": "^4.13.1",
"react-intersection-observer": "^10.0.2",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
"react-redux": "^7.2.9",
"react-resize-detector": "^7.1.2",
"react-resize-detector": "^9.1.1",
"react-reverse-portal": "^2.3.0",
"react-router-dom": "^5.3.4",
"react-search-input": "^0.11.3",
@@ -216,46 +216,46 @@
"urijs": "^1.19.8",
"use-event-callback": "^0.1.0",
"use-immer": "^0.11.0",
"use-query-params": "^1.1.9",
"use-query-params": "^2.2.2",
"uuid": "^13.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "^17.7.2"
},
"devDependencies": {
"@applitools/eyes-storybook": "^3.63.9",
"@applitools/eyes-storybook": "^3.63.10",
"@babel/cli": "^7.28.6",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/eslint-parser": "^7.28.6",
"@babel/node": "^7.28.6",
"@babel/node": "^7.29.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/preset-env": "^7.28.6",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.28.6",
"@babel/runtime-corejs3": "^7.28.6",
"@babel/runtime-corejs3": "^7.29.0",
"@babel/types": "^7.28.6",
"@cypress/react": "^8.0.2",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.58.0",
"@playwright/test": "^1.58.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-controls": "8.6.14",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/components": "8.6.14",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/react-webpack5": "8.6.14",
"@storybook/test": "^8.6.14",
"@storybook/addon-actions": "^8.6.15",
"@storybook/addon-controls": "^8.6.15",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-links": "^8.6.15",
"@storybook/addon-mdx-gfm": "^8.6.15",
"@storybook/components": "^8.6.15",
"@storybook/preview-api": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/react-webpack5": "^8.6.15",
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.14.0",
@@ -272,7 +272,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.0.10",
"@types/node": "^25.2.1",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -286,6 +286,7 @@
"@types/rison": "0.1.0",
"@types/sinon": "^17.0.3",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
@@ -294,12 +295,12 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"baseline-browser-mapping": "^2.9.18",
"baseline-browser-mapping": "^2.9.19",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^13.0.1",
"cross-env": "^10.1.0",
"css-loader": "^7.1.2",
"css-loader": "^7.1.3",
"css-minimizer-webpack-plugin": "^7.0.4",
"eslint": "^8.56.0",
"eslint-config-prettier": "^7.2.0",
@@ -322,7 +323,7 @@
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.15.4",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^11.1.5",
"fetch-mock": "^12.6.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"history": "^5.3.0",
"html-webpack-plugin": "^5.6.6",
@@ -359,9 +360,10 @@
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.3",
"webpack": "^5.104.1",
"webpack": "^5.105.0",
"webpack-bundle-analyzer": "^5.2.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",
@@ -386,7 +388,7 @@
"puppeteer": "^22.4.1",
"remark-gfm": "^3.0.1",
"underscore": "^1.13.7",
"jspdf": "^3.0.2",
"jspdf": "^4.0.0",
"nwsapi": "^2.2.13",
"@deck.gl/aggregation-layers": "~9.2.2",
"@deck.gl/core": "~9.2.2",

View File

@@ -12,8 +12,8 @@
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"install": "^0.13.0",

View File

@@ -18,12 +18,19 @@
*/
/**
* @fileoverview Editors API for Superset extension editor contributions.
* @fileoverview Editors API for Superset text editor integration.
*
* This module defines the interfaces and types for editor contributions to the
* Superset platform. Extensions can register custom text editor implementations
* (e.g., Monaco, CodeMirror) through the extension manifest, replacing the
* default Ace editor for specific languages.
* This module defines the interfaces and types for working with text editors
* in Superset. It provides:
*
* - `EditorHandle`: Imperative API for programmatically controlling editors
* (get/set content, cursor position, selections, annotations, completions)
* - `EditorProps`: Props contract for editor React components
* - `CompletionProvider`: Interface for registering custom autocomplete providers
* - Registration functions for custom editor implementations
*
* The API is editor-agnostic, supporting Ace, Monaco, CodeMirror, or any
* compliant implementation.
*/
import { ForwardRefExoticComponent, RefAttributes } from 'react';
@@ -36,69 +43,111 @@ export type { EditorContribution, EditorLanguage };
/**
* Represents a position in the editor (line and column).
* Both line and column are zero-based indices.
*
* @example
* // Position at the start of line 5, column 10
* const pos: Position = { line: 4, column: 9 };
*/
export interface Position {
/** Zero-based line number */
/** Zero-based line number (first line is 0) */
line: number;
/** Zero-based column number */
/** Zero-based column number (first column is 0) */
column: number;
}
/**
* Represents a range in the editor with start and end positions.
* Represents a contiguous range in the editor defined by start and end positions.
* The range is inclusive of the start position and exclusive of the end position.
*/
export interface Range {
/** Start position of the range */
/** Start position of the range (inclusive) */
start: Position;
/** End position of the range */
/** End position of the range (exclusive) */
end: Position;
}
/**
* Represents a selection in the editor.
* Represents a selection in the editor, extending Range with direction information.
* A selection is a highlighted range of text that can be manipulated.
*/
export interface Selection extends Range {
/** Direction of the selection */
/**
* Direction of the selection.
* - 'ltr': Selection was made left-to-right (anchor at start, cursor at end)
* - 'rtl': Selection was made right-to-left (anchor at end, cursor at start)
*/
direction?: 'ltr' | 'rtl';
}
/**
* Annotation severity levels for editor markers.
* Severity levels for editor annotations.
* Determines the visual style and icon used to display the annotation.
*/
export type AnnotationSeverity = 'error' | 'warning' | 'info';
/**
* Represents an annotation (marker/diagnostic) in the editor.
* Represents a diagnostic annotation displayed in the editor.
* Annotations are used to highlight issues like syntax errors, linting warnings,
* or informational messages at specific locations in the code.
*
* @example
* const annotation: EditorAnnotation = {
* line: 5,
* column: 10,
* message: 'Unknown column "user_id"',
* severity: 'error',
* source: 'sql-validator',
* };
*/
export interface EditorAnnotation {
/** Zero-based line number */
/** Zero-based line number where the annotation appears */
line: number;
/** Zero-based column number (optional) */
/** Zero-based column number for precise positioning (optional) */
column?: number;
/** Annotation message to display */
/** Human-readable message describing the issue or information */
message: string;
/** Severity level of the annotation */
/** Severity determines visual styling (red for error, yellow for warning, blue for info) */
severity: AnnotationSeverity;
/** Optional source of the annotation (e.g., "linter", "typescript") */
/** Identifies what produced this annotation (e.g., "linter", "sql-validator") */
source?: string;
}
/**
* Represents a keyboard shortcut binding.
* Defines a keyboard shortcut that triggers a custom action in the editor.
* Hotkeys allow binding key combinations to functions that manipulate
* the editor or perform other actions.
*
* @example
* const runQueryHotkey: EditorHotkey = {
* name: 'runQuery',
* key: 'Ctrl+Enter',
* description: 'Execute the current query',
* exec: (handle) => {
* const sql = handle.getValue();
* executeQuery(sql);
* },
* };
*/
export interface EditorHotkey {
/** Unique name for the hotkey command */
/** Unique identifier for this hotkey command */
name: string;
/** Key binding string (e.g., "Ctrl+Enter", "Alt+Enter") */
/**
* Key combination string. Format varies by editor but typically uses:
* - Modifiers: Ctrl, Alt, Shift, Meta (Cmd on Mac)
* - Separator: + (e.g., "Ctrl+Enter", "Ctrl+Shift+F")
*/
key: string;
/** Description of what the hotkey does */
/** Human-readable description shown in keyboard shortcut help */
description?: string;
/** Function to execute when the hotkey is triggered */
/** Callback invoked when the hotkey is pressed, receives the editor handle */
exec: (handle: EditorHandle) => void;
}
/**
* Completion item kinds for autocompletion.
* Categories for completion items, determining the icon displayed.
* Includes standard programming concepts plus SQL-specific types
* (table, column, schema, catalog, database).
*/
export type CompletionItemKind =
| 'text'
@@ -132,53 +181,87 @@ export type CompletionItemKind =
| 'database';
/**
* Represents a completion item for autocompletion.
* Represents a single item in the autocompletion dropdown.
* Completion items are suggestions shown to users as they type,
* allowing quick insertion of code snippets, keywords, or identifiers.
*
* @example
* const tableCompletion: CompletionItem = {
* label: 'users',
* insertText: 'users',
* kind: 'table',
* detail: 'public schema',
* documentation: 'User accounts table with profile information',
* };
*/
export interface CompletionItem {
/** Display label for the completion item */
/** Text displayed in the completion dropdown */
label: string;
/** Text to insert when the item is selected */
/** Text inserted into the editor when this item is selected */
insertText: string;
/** Kind of completion item for icon display */
/** Category of completion, determines the icon shown (e.g., table, column, function) */
kind: CompletionItemKind;
/** Optional documentation to show in the completion popup */
/** Extended description shown in a details pane or tooltip */
documentation?: string;
/** Optional detail text to show alongside the label */
/** Short additional info displayed next to the label (e.g., type, schema) */
detail?: string;
/** Sorting priority (higher numbers appear first) */
/** String used for sorting; items are sorted lexicographically by this value */
sortText?: string;
/** Text used for filtering completions */
/** String used for filtering; if omitted, label is used for matching user input */
filterText?: string;
}
/**
* Context provided to completion providers.
* Context information passed to completion providers when requesting suggestions.
* Contains details about how completion was triggered and the current environment.
*/
export interface CompletionContext {
/** Character that triggered the completion (if any) */
/** The character that triggered automatic completion (e.g., '.', ' '), if applicable */
triggerCharacter?: string;
/** How the completion was triggered */
/**
* How the completion was triggered:
* - 'invoke': User explicitly requested completion (e.g., Ctrl+Space)
* - 'automatic': Triggered automatically by typing a trigger character
*/
triggerKind: 'invoke' | 'automatic';
/** Language of the editor */
/** The language mode of the editor (e.g., 'sql', 'json') */
language: EditorLanguage;
/** Generic metadata passed from the host (e.g., SQL Lab can pass database context) */
/** Host-provided context (e.g., database ID, schema name for SQL completions) */
metadata?: Record<string, unknown>;
}
/**
* Provider interface for dynamic completions.
* Interface for providing dynamic autocompletion suggestions.
* Providers are invoked when the user triggers completion, allowing
* context-aware suggestions based on cursor position and editor content.
*
* @example
* const tableCompletionProvider: CompletionProvider = {
* id: 'sql-tables',
* triggerCharacters: [' ', '.'],
* provideCompletions: async (content, position, context) => {
* const dbId = context.metadata?.databaseId;
* const tables = await fetchTables(dbId);
* return tables.map(t => ({
* label: t.name,
* insertText: t.name,
* kind: 'table',
* }));
* },
* };
*/
export interface CompletionProvider {
/** Unique identifier for this provider */
/** Unique identifier for this provider, used for debugging and deduplication */
id: string;
/** Trigger characters that invoke this provider (e.g., '.', ' ') */
/** Characters that trigger this provider automatically when typed (e.g., '.', ' ') */
triggerCharacters?: string[];
/**
* Provide completions at the given position.
* @param content The editor content
* @param position The cursor position
* @param context Completion context with trigger info and metadata
* @returns Array of completion items or a promise that resolves to them
* Generate completion suggestions for the current cursor position.
*
* @param content Full text content of the editor
* @param position Current cursor position where completion was triggered
* @param context Additional context about the trigger and environment
* @returns Array of completion items, or a Promise resolving to them for async providers
*/
provideCompletions(
content: string,
@@ -188,98 +271,186 @@ export interface CompletionProvider {
}
/**
* A keyword for editor autocomplete.
* This is a generic format that editor implementations convert to their native format.
* Represents a static keyword for basic autocomplete.
* Keywords are simpler than CompletionItems and are used for static lists
* of suggestions (e.g., SQL keywords, table names) that don't require
* dynamic computation.
*
* Editor implementations convert these to their native completion format.
*
* @example
* const sqlKeywords: EditorKeyword[] = [
* { name: 'SELECT', meta: 'keyword', score: 100 },
* { name: 'FROM', meta: 'keyword', score: 100 },
* { name: 'users', value: 'users', meta: 'table', score: 50 },
* ];
*/
export interface EditorKeyword {
/** Display name of the keyword */
/** Display name shown in the completion dropdown */
name: string;
/** Value to insert when selected (defaults to name if not provided) */
/** Text to insert when selected; defaults to name if not provided */
value?: string;
/** Category/type of the keyword (e.g., "column", "table", "function") */
/** Category label shown alongside the name (e.g., "column", "table", "function") */
meta?: string;
/** Optional score for sorting (higher = more relevant) */
/** Sorting priority; higher scores appear first in the completion list */
score?: number;
}
/**
* Props that all editor implementations must accept.
* Props accepted by all editor component implementations.
* This interface defines the contract between Superset and editor components,
* ensuring consistent behavior regardless of the underlying editor library.
*/
export interface EditorProps {
/** Instance identifier */
/** Unique identifier for this editor instance */
id: string;
/** Controlled value */
/** Current editor content (controlled component pattern) */
value: string;
/** Content change handler */
/** Called when the editor content changes */
onChange: (value: string) => void;
/** Blur handler */
/** Called when the editor loses focus, with the current value */
onBlur?: (value: string) => void;
/** Cursor position change handler */
/** Called when the cursor position changes */
onCursorPositionChange?: (pos: Position) => void;
/** Selection change handler */
/** Called when the selection(s) change */
onSelectionChange?: (sel: Selection[]) => void;
/** Language mode for syntax highlighting */
/** Language mode for syntax highlighting and language features */
language: EditorLanguage;
/** Whether the editor is read-only */
/** When true, prevents editing (view-only mode) */
readOnly?: boolean;
/** Tab size in spaces */
/** Number of spaces per tab character */
tabSize?: number;
/** Whether to show line numbers */
/** Whether to display line numbers in the gutter */
lineNumbers?: boolean;
/** Whether to enable word wrap */
/** Whether long lines should wrap to the next visual line */
wordWrap?: boolean;
/** Linting/error annotations */
/** Diagnostic annotations to display (errors, warnings, info) */
annotations?: EditorAnnotation[];
/** Keyboard shortcuts */
/** Custom keyboard shortcuts */
hotkeys?: EditorHotkey[];
/** Static keywords for autocomplete */
/** Static keywords for basic autocomplete */
keywords?: EditorKeyword[];
/** CSS height (e.g., "100%", "500px") */
/** CSS height value (e.g., "100%", "500px", "calc(100vh - 200px)") */
height?: string;
/** CSS width (e.g., "100%", "800px") */
/** CSS width value (e.g., "100%", "800px") */
width?: string;
/** Callback when editor is ready with imperative handle */
/** Called when the editor is fully initialized, providing the imperative handle */
onReady?: (handle: EditorHandle) => void;
/** Host-specific context (e.g., database info from SQL Lab) */
/** Contextual data passed to completion providers (e.g., database ID, schema) */
metadata?: Record<string, unknown>;
/** Theme object for styling the editor */
/** Theme object for styling the editor to match Superset's appearance */
theme?: SupersetTheme;
}
/**
* Imperative API for controlling the editor programmatically.
*
* This handle provides a unified interface for interacting with text editors
* regardless of the underlying implementation (Ace, Monaco, CodeMirror, etc.).
* It can be used by any part of Superset that needs to manipulate editor content,
* read selections, or register custom behaviors.
*/
export interface EditorHandle {
/** Focus the editor */
focus(): void;
/** Get the current editor content */
getValue(): string;
/** Set the editor content */
setValue(value: string): void;
/** Get the current cursor position */
getCursorPosition(): Position;
/** Move the cursor to a specific position */
moveCursorToPosition(position: Position): void;
/** Get all selections in the editor */
getSelections(): Selection[];
/** Set the selection range */
setSelection(selection: Range): void;
/** Get the selected text */
getSelectedText(): string;
/** Insert text at the current cursor position */
insertText(text: string): void;
/** Execute a named editor command */
executeCommand(commandName: string): void;
/** Scroll to a specific line */
scrollToLine(line: number): void;
/** Set annotations (replaces existing) */
setAnnotations(annotations: EditorAnnotation[]): void;
/** Clear all annotations */
clearAnnotations(): void;
/**
* Register a completion provider for dynamic suggestions.
* Moves keyboard focus to the editor.
* Useful after programmatic operations to return user focus to the editing area.
*/
focus(): void;
/**
* Returns the complete text content of the editor.
* @returns The full editor content as a string
*/
getValue(): string;
/**
* Replaces the entire editor content with the provided value.
* This will clear any existing content and reset the undo history in most editors.
* @param value The new content to set
*/
setValue(value: string): void;
/**
* Returns the current cursor position in the editor.
* @returns Position object with zero-based line and column numbers
*/
getCursorPosition(): Position;
/**
* Moves the cursor to the specified position.
* @param position Target position with zero-based line and column numbers
*/
moveCursorToPosition(position: Position): void;
/**
* Returns all active selections in the editor.
* Most editors support multiple selections (e.g., via Ctrl+click).
* Each selection includes start/end positions and optional direction.
* @returns Array of Selection objects, empty array if no selections
*/
getSelections(): Selection[];
/**
* Sets the selection to the specified range.
* This replaces any existing selections with a single new selection.
* @param selection Range to select, with start and end positions
*/
setSelection(selection: Range): void;
/**
* Returns the text within the current selection.
* If multiple selections exist, behavior depends on the editor implementation
* (typically returns the primary/first selection's text).
* @returns The selected text, or empty string if no selection
*/
getSelectedText(): string;
/**
* Inserts text at the current cursor position.
* If text is selected, the selection is replaced with the inserted text.
* @param text The text to insert
*/
insertText(text: string): void;
/**
* Execute a named editor command.
*
* Note: Command names are editor-specific. For example:
* - Ace: 'centerselection', 'gotoline', 'fold', 'unfold'
* - Monaco: 'editor.action.formatDocument', 'editor.action.commentLine'
*
* Callers using this method should be aware of which editor is active
* or handle cases where the command may not exist.
*
* @param commandName The editor-specific command name to execute
*/
executeCommand(commandName: string): void;
/**
* Scrolls the editor viewport to bring the specified line into view.
* The exact positioning (top, center, bottom) depends on the editor implementation.
* @param line Zero-based line number to scroll to
*/
scrollToLine(line: number): void;
/**
* Sets diagnostic annotations (errors, warnings, info markers) in the editor.
* This replaces any previously set annotations.
* Annotations appear as markers in the gutter and/or inline decorations.
* @param annotations Array of annotations to display
*/
setAnnotations(annotations: EditorAnnotation[]): void;
/**
* Removes all annotations from the editor.
* Equivalent to calling setAnnotations([]).
*/
clearAnnotations(): void;
/**
* Registers a provider for dynamic autocompletion suggestions.
* The provider will be invoked when completion is triggered (manually or automatically).
* Multiple providers can be registered; their results are merged.
* @param provider The completion provider to register
* @returns A Disposable to unregister the provider
* @returns A Disposable that removes the provider when disposed
*/
registerCompletionProvider(provider: CompletionProvider): Disposable;
}

View File

@@ -30,44 +30,14 @@
*/
import { Event, Database, SupersetError, Column } from './core';
import { EditorHandle } from './editors';
/**
* Represents an SQL editor instance within a SQL Lab tab.
* Contains the editor content and associated database connection information.
* Provides imperative control over the code editor component.
* Allows extensions to manipulate text content, cursor position,
* selections, annotations, and register completion providers.
*/
export interface Editor {
/**
* The SQL content of the editor.
* This represents the current text in the SQL editor.
*/
content: string;
/**
* The database identifier associated with the editor.
* This determines which database the queries will be executed against.
*/
databaseId: number;
/**
* The catalog name associated with the editor.
* Can be null if no specific catalog is selected.
*/
catalog: string | null;
/**
* The schema name associated with the editor.
* Defines the database schema context for the editor.
*/
schema: string;
/**
* The table name associated with the editor.
* Can be null if no specific table is selected.
*
* @todo Revisit if we actually need the table property
*/
table: string | null;
}
export interface Editor extends EditorHandle {}
/**
* Represents a panel within a SQL Lab tab.
@@ -99,10 +69,40 @@ export interface Tab {
title: string;
/**
* The SQL editor instance associated with this tab.
* Contains the editor content and database connection settings.
* The database identifier for this tab's query context.
* This determines which database the queries will be executed against.
*/
editor: Editor;
databaseId: number;
/**
* The catalog name for this tab's query context.
* Can be null if no specific catalog is selected (for multi-catalog databases like Trino).
*/
catalog: string | null;
/**
* The schema name for this tab's query context.
* Can be null if no schema is selected.
*/
schema: string | null;
/**
* Gets the code editor instance for this tab.
* Returns a Promise that resolves when the editor is ready.
* The returned editor is a proxy that always delegates to the current
* editor implementation, even if the editor is swapped (e.g., Ace to Monaco).
*
* @returns Promise that resolves to the Editor instance
*
* @example
* ```typescript
* const tab = sqlLab.getCurrentTab();
* const editor = await tab.getEditor();
* editor.setValue("SELECT * FROM users");
* editor.focus();
* ```
*/
getEditor(): Promise<Editor>;
/**
* The panels associated with the tab.
@@ -262,7 +262,12 @@ export declare const getActivePanel: () => Panel;
* const tab = getCurrentTab();
* if (tab) {
* console.log(`Active tab: ${tab.title}`);
* console.log(`Database ID: ${tab.editor.databaseId}`);
* console.log(`Database ID: ${tab.databaseId}, Schema: ${tab.schema}`);
*
* // Editor manipulation via async getEditor()
* const editor = await tab.getEditor();
* editor.setValue("SELECT * FROM users");
* editor.focus();
* }
* ```
*/
@@ -326,9 +331,10 @@ export declare const onDidChangeTabTitle: Event<string>;
*
* @example
* ```typescript
* onDidQueryRun.event((query) => {
* console.log('Query started on database:', query.tab.editor.databaseId);
* console.log('Query content:', query.tab.editor.content);
* onDidQueryRun.event(async (query) => {
* console.log('Query started on database:', query.tab.databaseId);
* const editor = await query.tab.getEditor();
* console.log('Query SQL:', editor.getValue());
* });
* ```
*/
@@ -341,7 +347,7 @@ export declare const onDidQueryRun: Event<QueryContext>;
* @example
* ```typescript
* onDidQueryStop.event((query) => {
* console.log('Query stopped for database:', query.tab.editor.databaseId);
* console.log('Query stopped for database:', query.tab.databaseId);
* });
* ```
*/
@@ -444,3 +450,253 @@ export declare const onDidCloseTab: Event<Tab>;
* ```
*/
export declare const onDidChangeActiveTab: Event<Tab>;
/**
* Event fired when a new tab is created in SQL Lab.
* Provides the newly created tab object as the event payload.
*
* @example
* ```typescript
* onDidCreateTab.event((tab) => {
* console.log('New tab created:', tab.title);
* // Initialize extension state for new tab
* });
* ```
*/
export declare const onDidCreateTab: Event<Tab>;
/**
* Tab/Editor Management APIs
*
* These APIs allow extensions to create, close, and manage SQL Lab tabs.
*/
/**
* Options for creating a new SQL Lab tab.
*/
export interface CreateTabOptions {
/**
* Initial SQL content for the editor.
*/
sql?: string;
/**
* Display title for the tab.
* If not provided, defaults to "Untitled Query N".
*/
title?: string;
/**
* Database ID to connect to.
* If not provided, inherits from the active tab or uses default.
*/
databaseId?: number;
/**
* Catalog name (for multi-catalog databases like Trino).
*/
catalog?: string | null;
/**
* Schema name for the query context.
*/
schema?: string | null;
}
/**
* Creates a new query editor tab in SQL Lab.
*
* @param options Optional configuration for the new tab
* @returns The newly created tab object
*
* @example
* ```typescript
* // Create a tab with default settings
* const tab = await createTab();
*
* // Create a tab with specific SQL and database
* const tab = await createTab({
* sql: "SELECT * FROM users LIMIT 10",
* title: "User Query",
* databaseId: 1,
* schema: "public"
* });
* ```
*/
export declare function createTab(options?: CreateTabOptions): Promise<Tab>;
/**
* Closes a specific tab in SQL Lab.
*
* @param tabId The ID of the tab to close
* @returns Promise that resolves when the tab is closed
*
* @example
* ```typescript
* const tabs = getTabs();
* if (tabs.length > 1) {
* await closeTab(tabs[0].id);
* }
* ```
*/
export declare function closeTab(tabId: string): Promise<void>;
/**
* Switches to a specific tab in SQL Lab.
*
* @param tabId The ID of the tab to activate
* @returns Promise that resolves when the tab is activated
*
* @example
* ```typescript
* const tabs = getTabs();
* const targetTab = tabs.find(t => t.title === "My Query");
* if (targetTab) {
* await setActiveTab(targetTab.id);
* }
* ```
*/
export declare function setActiveTab(tabId: string): Promise<void>;
/**
* Query Execution APIs
*
* These APIs allow extensions to execute and control SQL queries.
*/
/**
* Options for executing a SQL query.
*/
export interface QueryOptions {
/**
* SQL to execute without modifying editor content.
* If not provided, uses the current editor content.
*/
sql?: string;
/**
* Run only the selected text in the editor.
* Ignored if `sql` option is provided.
*/
selectedOnly?: boolean;
/**
* Override the query row limit.
* If not provided, uses the tab's configured limit.
*/
limit?: number;
/**
* Template parameters for Jinja templating.
* Merged with existing template parameters from the editor.
*/
templateParameters?: Record<string, unknown>;
/**
* Create Table/View As Select options.
* When provided, query results are stored in a new table instead of returned directly.
*/
ctas?: {
/**
* Whether to create a TABLE or VIEW.
*/
method: 'TABLE' | 'VIEW';
/**
* Name of the table or view to create.
*/
tableName: string;
};
}
/**
* Executes a SQL query in the current tab.
*
* @param options Optional query execution options
* @returns Promise that resolves with the query ID
*
* @example
* ```typescript
* // Execute the current editor content
* const queryId = await executeQuery();
*
* // Execute custom SQL without modifying the editor
* const queryId = await executeQuery({
* sql: "SELECT * FROM users LIMIT 10"
* });
*
* // Execute only selected text
* const queryId = await executeQuery({ selectedOnly: true });
*
* // Create a table from query results
* const queryId = await executeQuery({
* ctas: { method: 'TABLE', tableName: 'my_results' }
* });
* ```
*/
export declare function executeQuery(options?: QueryOptions): Promise<string>;
/**
* Cancels a running query.
*
* @param queryId The client ID of the query to cancel
* @returns Promise that resolves when the cancellation request is sent
*
* @example
* ```typescript
* const queryId = await executeQuery();
* // Later, if needed:
* await cancelQuery(queryId);
* ```
*/
export declare function cancelQuery(queryId: string): Promise<void>;
/**
* Tab Context APIs
*
* These APIs manage tab-level query context and settings.
* Text manipulation is handled directly via Editor (e.g., tab.editor.setValue(sql)).
*/
/**
* Sets the database for the current tab.
*
* @param databaseId The ID of the database to set
* @returns Promise that resolves when the database is updated
*
* @example
* ```typescript
* const databases = getDatabases();
* const prodDb = databases.find(d => d.database_name === "production");
* if (prodDb) {
* await setDatabase(prodDb.id);
* }
* ```
*/
export declare function setDatabase(databaseId: number): Promise<void>;
/**
* Sets the catalog for the current tab.
*
* @param catalog The catalog name to set, or null to clear
* @returns Promise that resolves when the catalog is updated
*
* @example
* ```typescript
* await setCatalog("hive_metastore");
* ```
*/
export declare function setCatalog(catalog: string | null): Promise<void>;
/**
* Sets the schema for the current tab.
*
* @param schema The schema name to set, or null to clear
* @returns Promise that resolves when the schema is updated
*
* @example
* ```typescript
* await setSchema("public");
* ```
*/
export declare function setSchema(schema: string | null): Promise<void>;

View File

@@ -29,8 +29,9 @@ import {
FieldStringOutlined,
NumberOutlined,
} from '@ant-design/icons';
import { Icons } from '@superset-ui/core/components';
export type ColumnLabelExtendedType = 'expression' | '';
export type ColumnLabelExtendedType = 'expression' | 'metric' | '';
export type ColumnTypeLabelProps = {
type?: ColumnLabelExtendedType | GenericDataType;
@@ -59,7 +60,9 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
<QuestionOutlined aria-label={t('unknown type icon')} />
);
if (type === '' || type === 'expression') {
if (type === 'metric') {
typeIcon = <Icons.Sigma aria-label={t('metric type icon')} />;
} else if (type === '' || type === 'expression') {
typeIcon = <FunctionOutlined aria-label={t('function type icon')} />;
} else if (type === GenericDataType.String) {
typeIcon = <FieldStringOutlined aria-label={t('string type icon')} />;

View File

@@ -95,7 +95,7 @@ export function MetricOption({
return (
<FlexRowContainer className="metric-option">
{showType && <ColumnTypeLabel type="expression" />}
{showType && <ColumnTypeLabel type="metric" />}
{shouldShowTooltip ? (
<Tooltip id="metric-name-tooltip" title={tooltipText}>
{label}

View File

@@ -23,7 +23,7 @@ import { ControlPanelSectionConfig } from '../types';
import { formatSelectOptions } from '../utils';
export const TITLE_MARGIN_OPTIONS: number[] = [
15, 30, 50, 75, 100, 125, 150, 200,
0, 15, 30, 50, 75, 100, 125, 150, 200,
];
export const TITLE_POSITION_OPTIONS: [string, string][] = [
['Left', t('Left')],
@@ -82,7 +82,7 @@ export const titleControls: ControlPanelSectionConfig = {
clearable: true,
label: t('Y Axis Title Margin'),
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[1],
default: TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
},
},

View File

@@ -52,6 +52,10 @@ describe('ColumnOption', () => {
renderColumnTypeLabel({ type: 'expression' });
expect(screen.getByLabelText('function type icon')).toBeVisible();
});
it('metric type shows sigma icon', () => {
renderColumnTypeLabel({ type: 'metric' });
expect(screen.getByLabelText('metric type icon')).toBeVisible();
});
it('unknown type shows question mark', () => {
renderColumnTypeLabel({ type: undefined });
expect(screen.getByLabelText('unknown type icon')).toBeVisible();

View File

@@ -78,11 +78,11 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.23",
"@types/node": "^25.0.10",
"@types/node": "^25.2.1",
"@types/prop-types": "^15.7.15",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
"fetch-mock": "^11.1.4",
"fetch-mock": "^12.6.0",
"jest-mock-console": "^2.0.0",
"resize-observer-polyfill": "1.5.1",
"timezone-mock": "1.3.6"

View File

@@ -126,7 +126,7 @@ export function Button(props: ButtonProps) {
minWidth: cta ? theme.sizeUnit * 36 : undefined,
minHeight: cta ? theme.sizeUnit * 8 : undefined,
marginLeft: 0,
'& + .superset-button': {
'& + .superset-button:not(.ant-btn-compact-item)': {
marginLeft: theme.sizeUnit * 2,
},
'& > span > :first-of-type': {

View File

@@ -76,6 +76,10 @@ import {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderAddOutlined,
FolderOpenOutlined,
FolderOutlined,
FolderViewOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -94,15 +98,18 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
MinusCircleOutlined,
MinusSquareOutlined,
MoonOutlined,
LoadingOutlined,
LoginOutlined,
MonitorOutlined,
MoreOutlined,
OrderedListOutlined,
PartitionOutlined,
PieChartOutlined,
PicCenterOutlined,
PlusCircleOutlined,
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
QuestionCircleOutlined,
@@ -217,6 +224,10 @@ const AntdIcons = {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderAddOutlined,
FolderOpenOutlined,
FolderOutlined,
FolderViewOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -240,13 +251,16 @@ const AntdIcons = {
MenuFoldOutlined,
MenuUnfoldOutlined,
MinusCircleOutlined,
MinusSquareOutlined,
MonitorOutlined,
MoonOutlined,
MoreOutlined,
OrderedListOutlined,
PartitionOutlined,
PieChartOutlined,
PicCenterOutlined,
PlusCircleOutlined,
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
ReloadOutlined,

View File

@@ -42,10 +42,12 @@ const customIcons = [
'Error',
'Full',
'Layers',
'Move',
'Multiple',
'Queued',
'Redo',
'Running',
'Sigma',
'Slack',
'Square',
'SortAsc',

View File

@@ -24,12 +24,18 @@ import { ImageLoader, type BackgroundPosition } from './ImageLoader';
global.URL.createObjectURL = jest.fn(() => '/local_url');
const blob = new Blob([], { type: 'image/png' });
beforeAll(() => {
fetchMock.mockGlobal();
});
afterAll(() => {
fetchMock.hardReset();
});
fetchMock.get(
'/thumbnail',
'glob:*/thumbnail',
{ body: blob, headers: { 'Content-Type': 'image/png' } },
{
sendAsJson: false,
},
{ name: 'thumbnail' },
);
describe('ImageLoader', () => {
@@ -44,7 +50,7 @@ describe('ImageLoader', () => {
return render(<ImageLoader {...props} />);
};
afterEach(() => fetchMock.resetHistory());
afterEach(() => fetchMock.clearHistory());
it('is a valid element', async () => {
setup();
@@ -57,7 +63,7 @@ describe('ImageLoader', () => {
'src',
'/fallback',
);
expect(fetchMock.calls(/thumbnail/)).toHaveLength(1);
expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1);
expect(global.URL.createObjectURL).toHaveBeenCalled();
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
'src',
@@ -66,13 +72,14 @@ describe('ImageLoader', () => {
});
it('displays fallback image when response is not an image', async () => {
fetchMock.once('/thumbnail2', {});
setup({ src: '/thumbnail2' });
fetchMock.once('glob:*/thumbnail2', {}, { name: 'thumbnail2' });
setup({ src: 'glob:*/thumbnail2' });
expect(screen.getByTestId('image-loader')).toHaveAttribute(
'src',
'/fallback',
);
expect(fetchMock.calls(/thumbnail2/)).toHaveLength(1);
expect(fetchMock.callHistory.calls(/thumbnail2/)).toHaveLength(1);
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
'src',
'/fallback',

View File

@@ -19,6 +19,15 @@
import { DatasourceType } from './types/Datasource';
const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
table: DatasourceType.Table,
query: DatasourceType.Query,
dataset: DatasourceType.Dataset,
sl_table: DatasourceType.SlTable,
saved_query: DatasourceType.SavedQuery,
semantic_view: DatasourceType.SemanticView,
};
export default class DatasourceKey {
readonly id: number;
@@ -27,8 +36,7 @@ export default class DatasourceKey {
constructor(key: string) {
const [idStr, typeStr] = key.split('__');
this.id = parseInt(idStr, 10);
this.type = DatasourceType.Table; // default to SqlaTable model
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
}
public toString() {

View File

@@ -26,6 +26,7 @@ export enum DatasourceType {
Dataset = 'dataset',
SlTable = 'sl_table',
SavedQuery = 'saved_query',
SemanticView = 'semantic_view',
}
export interface Currency {

View File

@@ -34,8 +34,10 @@ export enum FeatureFlag {
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
CssTemplates = 'CSS_TEMPLATES',
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
DashboardRbac = 'DASHBOARD_RBAC',
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
DatasetFolders = 'DATASET_FOLDERS',
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
/** @deprecated */
DrillToDetail = 'DRILL_TO_DETAIL',

View File

@@ -25,6 +25,7 @@ export { default as isEqualArray } from './isEqualArray';
export { default as makeSingleton } from './makeSingleton';
export { default as promiseTimeout } from './promiseTimeout';
export { default as removeDuplicates } from './removeDuplicates';
export { default as withLabel } from './withLabel';
export { lruCache } from './lruCache';
export { getSelectedText } from './getSelectedText';
export * from './featureFlags';

View File

@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ValidatorFunction } from '../validator';
/**
* Wraps a validator function to prepend a label to its error message.
*
* @param validator - The validator function to wrap
* @param label - The label to prepend to error messages
* @returns A new validator function that includes the label in error messages
*
* @example
* validators: [
* withLabel(validateInteger, t('Row limit')),
* ]
* // Returns: "Row limit is expected to be an integer"
*/
export default function withLabel<V = unknown, S = unknown>(
validator: ValidatorFunction<V, S>,
label: string,
): ValidatorFunction<V, S> {
return (value: V, state?: S): string | false => {
const error = validator(value, state);
return error ? `${label} ${error}` : false;
};
}

View File

@@ -17,6 +17,7 @@
* under the License.
*/
export * from './types';
export { default as legacyValidateInteger } from './legacyValidateInteger';
export { default as legacyValidateNumber } from './legacyValidateNumber';
export { default as validateInteger } from './validateInteger';

View File

@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
* formerly called integer()
* @param v
*/
export default function legacyValidateInteger(v: unknown) {
export default function legacyValidateInteger(v: unknown): string | false {
if (
v &&
(Number.isNaN(Number(v)) || parseInt(v as string, 10) !== Number(v))

View File

@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
* formerly called numeric()
* @param v
*/
export default function numeric(v: unknown) {
export default function numeric(v: unknown): string | false {
if (v && Number.isNaN(Number(v))) {
return t('is expected to be a number');
}

View File

@@ -0,0 +1,27 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Type definition for a validator function.
* Returns an error message string if validation fails, or false if validation passes.
*/
export type ValidatorFunction<V = unknown, S = unknown> = (
value: V,
state?: S,
) => string | false;

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core';
export default function validateInteger(v: unknown) {
export default function validateInteger(v: unknown): string | false {
if (
(typeof v === 'string' &&
v.trim().length > 0 &&

View File

@@ -25,7 +25,7 @@ const VALIDE_OSM_URLS = ['https://tile.osm', 'https://tile.openstreetmap'];
* Validate a [Mapbox styles URL](https://docs.mapbox.com/help/glossary/style-url/)
* @param v
*/
export default function validateMapboxStylesUrl(v: unknown) {
export default function validateMapboxStylesUrl(v: unknown): string | false {
if (typeof v === 'string') {
const trimmed_v = v.trim();
if (

View File

@@ -18,7 +18,10 @@
*/
import { t } from '@apache-superset/core';
export default function validateMaxValue(v: unknown, max: number) {
export default function validateMaxValue(
v: unknown,
max: number,
): string | false {
if (Number(v) > +max) {
return t('Value cannot exceed %s', max);
}

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core';
export default function validateNonEmpty(v: unknown) {
export default function validateNonEmpty(v: unknown): string | false {
if (
v === null ||
typeof v === 'undefined' ||

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core';
export default function validateInteger(v: any) {
export default function validateNumber(v: unknown): string | false {
if (
(typeof v === 'string' &&
v.trim().length > 0 &&

View File

@@ -23,7 +23,7 @@ export default function validateServerPagination(
serverPagination: boolean,
maxValueWithoutServerPagination: number,
maxServer: number,
) {
): string | false {
if (
Number(v) > +maxValueWithoutServerPagination &&
Number(v) <= maxServer &&

View File

@@ -22,13 +22,13 @@ import { t } from '@apache-superset/core';
import { ensureIsArray } from '../utils';
export const validateTimeComparisonRangeValues = (
timeRangeValue?: any,
controlValue?: any,
) => {
timeRangeValue?: unknown,
controlValue?: unknown,
): string[] => {
const isCustomTimeRange = timeRangeValue === ComparisonTimeRangeType.Custom;
const isCustomControlEmpty = controlValue?.every(
(val: any) => ensureIsArray(val).length === 0,
);
const isCustomControlEmpty =
Array.isArray(controlValue) &&
controlValue.every((val: unknown) => ensureIsArray(val).length === 0);
return isCustomTimeRange && isCustomControlEmpty
? [t('Filters for comparison must have a value')]
: [];

View File

@@ -37,6 +37,9 @@ import { SliceIdAndOrFormData } from '../../../src/chart/clients/ChartClient';
configureTranslation();
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('ChartClient', () => {
let chartClient: ChartClient;
@@ -50,7 +53,7 @@ describe('ChartClient', () => {
chartClient = new ChartClient();
});
afterEach(() => fetchMock.restore());
afterEach(() => fetchMock.removeRoutes().clearHistory());
describe('new ChartClient(config)', () => {
it('creates a client without argument', () => {

View File

@@ -21,10 +21,13 @@ import fetchMock from 'fetch-mock';
import { SupersetClient, SupersetClientClass } from '@superset-ui/core';
import { LOGIN_GLOB } from './fixtures/constants';
describe('SupersetClient', () => {
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '' }));
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
afterAll(() => fetchMock.restore());
describe('SupersetClient', () => {
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
afterAll(() => fetchMock.removeRoutes().clearHistory());
afterEach(() => SupersetClient.reset());
@@ -108,9 +111,11 @@ describe('SupersetClient', () => {
mockDeleteUrl,
];
networkCalls.map((url: string) =>
expect(fetchMock.calls(url)[0][1]?.headers).toStrictEqual({
Accept: 'application/json',
'X-CSRFToken': '1234',
expect(
fetchMock.callHistory.calls(url)[0].options?.headers,
).toStrictEqual({
accept: 'application/json',
'x-csrftoken': '1234',
}),
);
@@ -137,6 +142,6 @@ describe('SupersetClient', () => {
authenticatedSpy.mockRestore();
csrfSpy.mockRestore();
fetchMock.reset();
fetchMock.clearHistory().removeRoutes();
});
});

View File

@@ -20,14 +20,15 @@ import fetchMock from 'fetch-mock';
import { SupersetClientClass, ClientConfig, CallApi } from '@superset-ui/core';
import { LOGIN_GLOB } from './fixtures/constants';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('SupersetClientClass', () => {
beforeEach(() => {
fetchMock.reset();
fetchMock.get(LOGIN_GLOB, { result: '' });
fetchMock.clearHistory().removeRoutes();
fetchMock.get(LOGIN_GLOB, { result: '' }, { name: LOGIN_GLOB });
});
afterAll(() => fetchMock.restore());
describe('new SupersetClientClass()', () => {
it('fallback protocol to https when setting only host', () => {
const client = new SupersetClientClass({ host: 'TEST-HOST' });
@@ -89,21 +90,22 @@ describe('SupersetClientClass', () => {
});
describe('.init()', () => {
beforeEach(() =>
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true }),
);
afterEach(() => fetchMock.reset());
beforeEach(() => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
});
afterEach(() => fetchMock.clearHistory().removeRoutes());
it('calls api/v1/security/csrf_token/ when init() is called if no CSRF token is passed', async () => {
expect.assertions(1);
// expect.assertions(1);
await new SupersetClientClass().init();
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1);
});
it('does NOT call api/v1/security/csrf_token/ when init() is called if a CSRF token is passed', async () => {
expect.assertions(1);
await new SupersetClientClass({ csrfToken: 'abc' }).init();
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0);
});
it('calls api/v1/security/csrf_token/ when init(force=true) is called even if a CSRF token is passed', async () => {
@@ -112,20 +114,19 @@ describe('SupersetClientClass', () => {
const client = new SupersetClientClass({ csrfToken: initialToken });
await client.init();
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0);
expect(client.csrfToken).toBe(initialToken);
await client.init(true);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1);
expect(client.csrfToken).not.toBe(initialToken);
});
it('throws if api/v1/security/csrf_token/ returns an error', async () => {
expect.assertions(1);
const rejectError = { status: 403 };
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectError), {
overwriteRoutes: true,
});
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { throws: rejectError }, { name: LOGIN_GLOB });
let error;
try {
@@ -141,7 +142,7 @@ describe('SupersetClientClass', () => {
it('throws if api/v1/security/csrf_token/ does not return a token', async () => {
expect.assertions(1);
fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true });
fetchMock.modifyRoute(LOGIN_GLOB, { response: {} });
let error;
try {
@@ -157,9 +158,8 @@ describe('SupersetClientClass', () => {
it('does not set csrfToken if response is not json', async () => {
expect.assertions(1);
fetchMock.get(LOGIN_GLOB, '123', {
overwriteRoutes: true,
});
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { response: '123' }, { name: LOGIN_GLOB });
let error;
try {
@@ -175,7 +175,7 @@ describe('SupersetClientClass', () => {
});
describe('.isAuthenticated()', () => {
afterEach(() => fetchMock.reset());
afterEach(() => fetchMock.clearHistory().removeRoutes());
it('returns true if there is a token and false if not', async () => {
expect.assertions(2);
@@ -227,9 +227,8 @@ describe('SupersetClientClass', () => {
expect.assertions(4);
const rejectValue = { status: 403 };
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), {
overwriteRoutes: true,
});
fetchMock.removeRoutes();
fetchMock.get(LOGIN_GLOB, { throws: rejectValue }, { name: LOGIN_GLOB });
const client = new SupersetClientClass({});
let error;
@@ -253,18 +252,19 @@ describe('SupersetClientClass', () => {
}
// reset
fetchMock.removeRoutes();
fetchMock.get(
LOGIN_GLOB,
{ result: 1234 },
{
overwriteRoutes: true,
name: LOGIN_GLOB,
},
);
});
});
describe('requests', () => {
afterEach(() => fetchMock.restore());
afterEach(() => fetchMock.clearHistory().removeRoutes());
const protocol = 'https:';
const host = 'host';
@@ -306,11 +306,11 @@ describe('SupersetClientClass', () => {
await client.delete({ url: mockDeleteUrl });
await client.request({ url: mockRequestUrl, method: 'DELETE' });
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockDeleteUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockRequestUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockDeleteUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockRequestUrl)).toHaveLength(1);
expect(authSpy).toHaveBeenCalledTimes(5);
authSpy.mockRestore();
@@ -331,7 +331,8 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl });
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
.options as CallApi;
expect(fetchRequest.mode).toBe(clientConfig.mode);
expect(fetchRequest.credentials).toBe(clientConfig.credentials);
expect(fetchRequest.headers).toEqual(
@@ -354,10 +355,11 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl });
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
.options as CallApi;
expect(fetchRequest.headers).toEqual(
expect.objectContaining({
guestTokenHeader: 'abc123',
guesttokenheader: 'abc123',
}),
);
});
@@ -370,10 +372,10 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl });
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
await client.get({ endpoint: mockGetEndpoint });
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(2);
});
it('supports parsing a response as text', async () => {
@@ -384,7 +386,7 @@ describe('SupersetClientClass', () => {
url: mockTextUrl,
parseMethod: 'text',
});
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
});
@@ -409,7 +411,8 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl, ...overrideConfig });
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
.options as CallApi;
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
@@ -428,10 +431,10 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl });
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
await client.post({ endpoint: mockPostEndpoint });
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(2);
});
it('allows overriding host, headers, mode, and credentials per-request', async () => {
@@ -454,7 +457,8 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl, ...overrideConfig });
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
.options as CallApi;
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
@@ -473,7 +477,7 @@ describe('SupersetClientClass', () => {
url: mockTextUrl,
parseMethod: 'text',
});
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
});
@@ -485,10 +489,11 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl, postPayload });
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
.options as CallApi;
const formData = fetchRequest.body as FormData;
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(JSON.stringify(value));
});
@@ -502,10 +507,11 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl, postPayload, stringify: false });
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
.options as CallApi;
const formData = fetchRequest.body as FormData;
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(String(value));
});
@@ -528,6 +534,7 @@ describe('SupersetClientClass', () => {
// @ts-ignore
window.location = {
pathname: mockRequestPath,
// @ts-ignore
search: mockRequestSearch,
href: mockHref,
};
@@ -535,9 +542,7 @@ describe('SupersetClientClass', () => {
.spyOn(SupersetClientClass.prototype, 'ensureAuth')
.mockImplementation();
const rejectValue = { status: 401 };
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue), {
overwriteRoutes: true,
});
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue));
});
afterEach(() => {
@@ -563,10 +568,11 @@ describe('SupersetClientClass', () => {
it('should not redirect again if already on login page', async () => {
const client = new SupersetClientClass({});
// @ts-expect-error
// @ts-ignore
window.location = {
href: '/login?next=something',
pathname: '/login',
// @ts-ignore
search: '?next=something',
};
@@ -636,7 +642,8 @@ describe('SupersetClientClass', () => {
let createElement: any;
beforeEach(async () => {
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true });
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
client = new SupersetClientClass({ protocol, host });
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');

View File

@@ -29,14 +29,17 @@ const corruptObject = new BadObject();
/* @ts-expect-error */
BadObject.prototype.toString = undefined;
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
const mockPutUrl = '/mock/put/url';
const mockPatchUrl = '/mock/patch/url';
const mockCacheUrl = '/mock/cache/url';
const mockNotFound = '/mock/notfound';
const mockErrorUrl = '/mock/error/url';
const mock503 = '/mock/503';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
const mockGetUrl = 'glob:*/mock/get/url';
const mockPostUrl = 'glob:*/mock/post/url';
const mockPutUrl = 'glob:*/mock/put/url';
const mockPatchUrl = 'glob:*/mock/patch/url';
const mockCacheUrl = 'glob:*/mock/cache/url';
const mockNotFound = 'glob:*/mock/notfound';
const mockErrorUrl = 'glob:*/mock/error/url';
const mock503 = 'glob:*/mock/503';
const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' };
@@ -50,20 +53,23 @@ const mockCachePayload = {
const mockErrorPayload = { status: 500, statusText: 'Internal error' };
describe('callApi()', () => {
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
beforeAll(() => {
fetchMock.mockGlobal();
fetchMock.get(LOGIN_GLOB, { result: '1234' });
});
beforeEach(() => {
fetchMock.get(mockGetUrl, mockGetPayload);
fetchMock.post(mockPostUrl, mockPostPayload);
fetchMock.put(mockPutUrl, mockPutPayload);
fetchMock.patch(mockPatchUrl, mockPatchPayload);
fetchMock.get(mockCacheUrl, mockCachePayload);
fetchMock.get(mockCacheUrl, mockCachePayload, { name: mockCacheUrl });
fetchMock.get(mockNotFound, { status: 404 });
fetchMock.get(mock503, { status: 503 });
fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload));
});
afterEach(() => fetchMock.reset());
afterEach(() => fetchMock.clearHistory().removeRoutes());
describe('request config', () => {
it('calls the right url with the specified method', async () => {
@@ -74,10 +80,10 @@ describe('callApi()', () => {
callApi({ url: mockPutUrl, method: 'PUT' }),
callApi({ url: mockPatchUrl, method: 'PATCH' }),
]);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPatchUrl)).toHaveLength(1);
});
it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', async () => {
@@ -92,12 +98,11 @@ describe('callApi()', () => {
},
redirect: 'follow',
signal: undefined,
body: 'BODY',
};
await callApi(mockRequest);
const calls = fetchMock.calls(mockGetUrl);
const fetchParams = calls[0][1] as RequestInit;
const calls = fetchMock.callHistory.calls(mockGetUrl);
const fetchParams = calls[0].options as RequestInit;
expect(calls).toHaveLength(1);
expect(fetchParams.mode).toBe(mockRequest.mode);
expect(fetchParams.cache).toBe(mockRequest.cache);
@@ -119,10 +124,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', anotherKey: 1237 };
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.calls(mockPostUrl);
const calls = fetchMock.callHistory.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1] as RequestInit;
const fetchParams = calls[0].options as RequestInit;
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -136,10 +141,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', noValue: undefined };
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.calls(mockPostUrl);
const calls = fetchMock.callHistory.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1] as RequestInit;
const fetchParams = calls[0].options as RequestInit;
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
@@ -167,13 +172,13 @@ describe('callApi()', () => {
}),
callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }),
]);
const calls = fetchMock.calls(mockPostUrl);
const calls = fetchMock.callHistory.calls(mockPostUrl);
expect(calls).toHaveLength(3);
const stringified = (calls[0][1] as RequestInit).body as FormData;
const unstringified = (calls[1][1] as RequestInit).body as FormData;
const stringified = (calls[0].options as RequestInit).body as FormData;
const unstringified = (calls[1].options as RequestInit).body as FormData;
const jsonRequestBody = JSON.parse(
(calls[2][1] as RequestInit).body as string,
(calls[2].options as RequestInit).body as string,
) as JsonObject;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -211,9 +216,9 @@ describe('callApi()', () => {
stringify: false,
});
const calls = fetchMock.calls(mockPostUrl);
const calls = fetchMock.callHistory.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const unstringified = (calls[0][1] as RequestInit).body as FormData;
const unstringified = (calls[0].options as RequestInit).body as FormData;
const hasCorruptKey = unstringified.has('corrupt');
expect(hasCorruptKey).toBeFalsy();
// When a corrupt attribute is encountered, a console.error call is made with info about the corrupt attribute
@@ -228,10 +233,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', anotherKey: 1237 };
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.calls(mockPutUrl);
const calls = fetchMock.callHistory.calls(mockPutUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1] as RequestInit;
const fetchParams = calls[0].options as RequestInit;
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -245,10 +250,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', noValue: undefined };
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.calls(mockPutUrl);
const calls = fetchMock.callHistory.calls(mockPutUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1] as RequestInit;
const fetchParams = calls[0].options as RequestInit;
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
@@ -275,11 +280,11 @@ describe('callApi()', () => {
stringify: false,
}),
]);
const calls = fetchMock.calls(mockPutUrl);
const calls = fetchMock.callHistory.calls(mockPutUrl);
expect(calls).toHaveLength(2);
const stringified = (calls[0][1] as RequestInit).body as FormData;
const unstringified = (calls[1][1] as RequestInit).body as FormData;
const stringified = (calls[0].options as RequestInit).body as FormData;
const unstringified = (calls[1].options as RequestInit).body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
@@ -294,10 +299,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', anotherKey: 1237 };
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.calls(mockPatchUrl);
const calls = fetchMock.callHistory.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1] as RequestInit;
const fetchParams = calls[0].options as RequestInit;
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -311,10 +316,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', noValue: undefined };
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.calls(mockPatchUrl);
const calls = fetchMock.callHistory.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1] as RequestInit;
const fetchParams = calls[0].options as RequestInit;
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
@@ -341,11 +346,11 @@ describe('callApi()', () => {
stringify: false,
}),
]);
const calls = fetchMock.calls(mockPatchUrl);
const calls = fetchMock.callHistory.calls(mockPatchUrl);
expect(calls).toHaveLength(2);
const stringified = (calls[0][1] as RequestInit).body as FormData;
const unstringified = (calls[1][1] as RequestInit).body as FormData;
const stringified = (calls[0].options as RequestInit).body as FormData;
const unstringified = (calls[1].options as RequestInit).body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
@@ -373,7 +378,7 @@ describe('callApi()', () => {
it('caches requests with ETags', async () => {
expect.assertions(2);
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl);
const calls = fetchMock.callHistory.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
const supersetCache = await caches.open(constants.CACHE_KEY);
const cachedResponse = await supersetCache.match(mockCacheUrl);
@@ -385,7 +390,7 @@ describe('callApi()', () => {
window.location.protocol = 'http:';
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl);
const calls = fetchMock.callHistory.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
const supersetCache = await caches.open(constants.CACHE_KEY);
@@ -399,7 +404,7 @@ describe('callApi()', () => {
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false });
const firstResponse = await callApi({ url: mockCacheUrl, method: 'GET' });
let calls = fetchMock.calls(mockCacheUrl);
let calls = fetchMock.callHistory.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
const firstBody = await firstResponse.text();
expect(firstBody).toEqual('BODY');
@@ -408,8 +413,8 @@ describe('callApi()', () => {
url: mockCacheUrl,
method: 'GET',
});
calls = fetchMock.calls(mockCacheUrl);
const fetchParams = calls[1][1] as RequestInit;
calls = fetchMock.callHistory.calls(mockCacheUrl);
const fetchParams = calls[1].options as RequestInit;
expect(calls).toHaveLength(2);
// second call should not have If-None-Match header
expect(fetchParams.headers).toBeUndefined();
@@ -424,14 +429,14 @@ describe('callApi()', () => {
expect.assertions(3);
// first call sets the cache
await callApi({ url: mockCacheUrl, method: 'GET' });
let calls = fetchMock.calls(mockCacheUrl);
let calls = fetchMock.callHistory.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
// second call sends the Etag in the If-None-Match header
await callApi({ url: mockCacheUrl, method: 'GET' });
calls = fetchMock.calls(mockCacheUrl);
const fetchParams = calls[1][1] as RequestInit;
const headers = { 'If-None-Match': 'etag' };
calls = fetchMock.callHistory.calls(mockCacheUrl);
const fetchParams = calls[1].options as RequestInit;
const headers = { 'if-none-match': 'etag' };
expect(calls).toHaveLength(2);
expect(fetchParams.headers).toEqual(
expect.objectContaining(headers) as typeof fetchParams.headers,
@@ -442,16 +447,16 @@ describe('callApi()', () => {
expect.assertions(3);
// first call sets the cache
await callApi({ url: mockCacheUrl, method: 'GET' });
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(1);
// second call reuses the cached payload on a 304
const mockCachedPayload = { status: 304 };
fetchMock.get(mockCacheUrl, mockCachedPayload, { overwriteRoutes: true });
fetchMock.modifyRoute(mockCacheUrl, { response: mockCachedPayload });
const secondResponse = await callApi({
url: mockCacheUrl,
method: 'GET',
});
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(2);
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(2);
const secondBody = await secondResponse.text();
expect(secondBody).toEqual('BODY');
});
@@ -461,7 +466,7 @@ describe('callApi()', () => {
// this should never happen, since a 304 is only returned if we have
// the cached response and sent the If-None-Match header
const mockUncachedUrl = '/mock/uncached/url';
const mockUncachedUrl = 'glob:*/mock/uncached/url';
const mockCachedPayload = { status: 304 };
let error;
fetchMock.get(mockUncachedUrl, mockCachedPayload);
@@ -471,7 +476,7 @@ describe('callApi()', () => {
} catch (err) {
error = err;
} finally {
const calls = fetchMock.calls(mockUncachedUrl);
const calls = fetchMock.callHistory.calls(mockUncachedUrl);
expect(calls).toHaveLength(1);
expect((error as { message: string }).message).toEqual(
'Received 304 but no content is cached!',
@@ -483,7 +488,7 @@ describe('callApi()', () => {
expect.assertions(3);
const url = mockGetUrl;
const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.calls(url);
const calls = fetchMock.callHistory.calls(url);
expect(calls).toHaveLength(1);
expect(response.status).toEqual(200);
const body = await response.json();
@@ -494,7 +499,7 @@ describe('callApi()', () => {
expect.assertions(2);
const url = mockNotFound;
const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.calls(url);
const calls = fetchMock.callHistory.calls(url);
expect(calls).toHaveLength(1);
expect(response.status).toEqual(404);
});
@@ -513,7 +518,7 @@ describe('callApi()', () => {
error = err;
} finally {
const err = error as { status: number; statusText: string };
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4);
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(4);
expect(err.status).toBe(mockErrorPayload.status);
expect(err.statusText).toBe(mockErrorPayload.statusText);
}
@@ -531,7 +536,7 @@ describe('callApi()', () => {
} catch (err) {
error = err as { status: number; statusText: string };
} finally {
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(1);
expect(error?.status).toBe(mockErrorPayload.status);
expect(error?.statusText).toBe(mockErrorPayload.statusText);
}
@@ -545,7 +550,7 @@ describe('callApi()', () => {
url,
method: 'GET',
});
const calls = fetchMock.calls(url);
const calls = fetchMock.callHistory.calls(url);
expect(calls).toHaveLength(4);
expect(response.status).toEqual(503);
});
@@ -581,7 +586,9 @@ describe('callApi()', () => {
const result = await response.json();
expect(response.status).toEqual(200);
expect(result).toEqual({ yes: 'ok' });
expect(fetchMock.lastUrl()).toEqual(`http://localhost/get-search?abc=1`);
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
`http://localhost/get-search?abc=1`,
);
});
it('should accept URLSearchParams', async () => {
@@ -596,8 +603,10 @@ describe('callApi()', () => {
method: 'POST',
jsonPayload: { request: 'ok' },
});
expect(fetchMock.lastUrl()).toEqual(`http://localhost/post-search?abc=1`);
expect(fetchMock.lastOptions()).toEqual(
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
`http://localhost/post-search?abc=1`,
);
expect(fetchMock.callHistory.lastCall()?.options).toEqual(
expect.objectContaining({
body: JSON.stringify({ request: 'ok' }),
}),
@@ -634,7 +643,7 @@ describe('callApi()', () => {
method: 'POST',
postPayload: payload,
});
expect(fetchMock.lastOptions()?.body).toBe(payload);
expect(fetchMock.callHistory.lastCall()?.options.body).toBe(payload);
});
it('should ignore "null" postPayload string', async () => {
@@ -646,6 +655,6 @@ describe('callApi()', () => {
method: 'POST',
postPayload: 'null',
});
expect(fetchMock.lastOptions()?.body).toBeUndefined();
expect(fetchMock.callHistory.lastCall()?.options.body).toBeUndefined();
});
});

View File

@@ -30,15 +30,16 @@ import { LOGIN_GLOB } from '../fixtures/constants';
const mockGetUrl = '/mock/get/url';
const mockGetPayload = { get: 'payload' };
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('callApiAndParseWithTimeout()', () => {
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
beforeEach(() => fetchMock.get(mockGetUrl, mockGetPayload));
afterAll(() => fetchMock.restore());
afterEach(() => {
fetchMock.reset();
fetchMock.removeRoutes().clearHistory();
jest.useRealTimers();
});
@@ -108,7 +109,7 @@ describe('callApiAndParseWithTimeout()', () => {
} catch (err) {
error = err;
} finally {
expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockTimeoutUrl)).toHaveLength(1);
expect(error).toEqual({
error: 'Request timed out',
statusText: 'timeout',

View File

@@ -22,12 +22,15 @@ import parseResponse from '../../../src/connection/callApi/parseResponse';
import { LOGIN_GLOB } from '../fixtures/constants';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('parseResponse()', () => {
beforeAll(() => {
fetchMock.get(LOGIN_GLOB, { result: '1234' });
});
afterAll(() => fetchMock.restore());
afterAll(() => fetchMock.removeRoutes().clearHistory());
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
@@ -45,7 +48,7 @@ describe('parseResponse()', () => {
fetchMock.get(mockNoParseUrl, new Response('test response'));
});
afterEach(() => fetchMock.reset());
afterEach(() => fetchMock.removeRoutes().clearHistory());
it('returns a Promise', () => {
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' });
@@ -58,7 +61,7 @@ describe('parseResponse()', () => {
const args = await parseResponse(
callApi({ url: mockGetUrl, method: 'GET' }),
);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('json');
@@ -81,7 +84,7 @@ describe('parseResponse()', () => {
} catch (err) {
error = err as Error;
} finally {
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
expect(error?.stack).toBeDefined();
expect(error?.message).toContain('Unexpected token');
}
@@ -99,7 +102,7 @@ describe('parseResponse()', () => {
callApi({ url: mockTextParseUrl, method: 'GET' }),
'text',
);
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockTextParseUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('text');
@@ -134,7 +137,7 @@ describe('parseResponse()', () => {
callApi({ url: mockNoParseUrl, method: 'GET' }),
'raw',
);
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(2);
expect(fetchMock.callHistory.calls(mockNoParseUrl)).toHaveLength(2);
expect(responseNull.bodyUsed).toBe(false);
expect(responseRaw.bodyUsed).toBe(false);
});
@@ -193,7 +196,7 @@ describe('parseResponse()', () => {
} catch (err) {
error = err as { ok: boolean; status: number };
} finally {
expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockNotOkayUrl)).toHaveLength(1);
expect(error?.ok).toBe(false);
expect(error?.status).toBe(404);
}

View File

@@ -21,10 +21,13 @@ import { getDatasourceMetadata } from '../../../../src/query/api/legacy';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('getFormData()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.restore());
afterEach(() => fetchMock.clearHistory().removeRoutes());
it('returns datasource metadata for given datasource key', () => {
const mockData = {

View File

@@ -22,10 +22,13 @@ import { getFormData } from '../../../../src/query/api/legacy';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('getFormData()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.restore());
afterEach(() => fetchMock.clearHistory().removeRoutes());
const mockData = {
datasource: '1__table',

View File

@@ -20,9 +20,13 @@ import fetchMock from 'fetch-mock';
import { buildQueryContext, ApiV1, VizType } from '@superset-ui/core';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('API v1 > getChartData()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.restore());
afterEach(() => fetchMock.clearHistory().removeRoutes());
it('returns a promise of ChartDataResponse', async () => {
const response = {

View File

@@ -21,9 +21,13 @@ import { JsonValue, SupersetClientClass } from '@superset-ui/core';
import { makeApi, SupersetApiError } from '../../../../src/query';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('makeApi()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.restore());
afterEach(() => fetchMock.clearHistory().removeRoutes());
it('should expose method and endpoint', () => {
const api = makeApi({
@@ -95,7 +99,7 @@ describe('makeApi()', () => {
const expected = new FormData();
expected.append('request', JSON.stringify('test'));
const received = fetchMock.lastOptions()?.body as FormData;
const received = fetchMock.callHistory.lastCall()?.options.body as FormData;
expect(received).toBeInstanceOf(FormData);
expect(received.get('request')).toEqual(expected.get('request'));
@@ -109,7 +113,7 @@ describe('makeApi()', () => {
});
fetchMock.get('glob:*/test-get-search*', { search: 'get' });
await api({ p1: 1, p2: 2, p3: [1, 2] });
expect(fetchMock.lastUrl()).toContain(
expect(fetchMock.callHistory.lastCall()?.url).toContain(
'/test-get-search?p1=1&p2=2&p3=1%2C2',
);
});
@@ -123,7 +127,7 @@ describe('makeApi()', () => {
});
fetchMock.get('glob:*/test-post-search*', { rison: 'get' });
await api({ p1: 1, p3: [1, 2] });
expect(fetchMock.lastUrl()).toContain(
expect(fetchMock.callHistory.lastCall()?.url).toContain(
'/test-post-search?q=(p1:1,p3:!(1,2))',
);
});
@@ -137,7 +141,9 @@ describe('makeApi()', () => {
});
fetchMock.post('glob:*/test-post-search*', { search: 'post' });
await api({ p1: 1, p3: [1, 2] });
expect(fetchMock.lastUrl()).toContain('/test-post-search?p1=1&p3=1%2C2');
expect(fetchMock.callHistory.lastCall()?.url).toContain(
'/test-post-search?p1=1&p3=1%2C2',
);
});
it('should throw when requestType is invalid', () => {
@@ -215,6 +221,8 @@ describe('makeApi()', () => {
fetchMock.delete('glob:*/test-raw-response?*', 'ok');
const result = await api({ field1: 11 }, {});
expect(result).toEqual(200);
expect(fetchMock.lastUrl()).toContain('/test-raw-response?field1=11');
expect(fetchMock.callHistory.lastCall()?.url).toContain(
'/test-raw-response?field1=11',
);
});
});

View File

@@ -25,7 +25,10 @@ import {
formatTimeRangeComparison,
} from '../../src/time-comparison/fetchTimeRange';
afterEach(() => fetchMock.restore());
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
afterEach(() => fetchMock.clearHistory().removeRoutes());
test('generates proper time range string', () => {
expect(
@@ -84,34 +87,41 @@ test('returns a formatted time range from empty response', async () => {
});
test('returns a formatted error message from response', async () => {
fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', {
throws: new Response(JSON.stringify({ message: 'Network error' })),
});
const getTimeRangeUrl = 'glob:*/api/v1/time_range/?q=%27Last+day%27';
fetchMock.get(
getTimeRangeUrl,
{
throws: new Response(JSON.stringify({ message: 'Network error' })),
},
{ name: getTimeRangeUrl },
);
let timeRange = await fetchTimeRange('Last day');
expect(timeRange).toEqual({
error: 'Network error',
});
fetchMock.removeRoute(getTimeRangeUrl);
fetchMock.get(
'glob:*/api/v1/time_range/?q=%27Last+day%27',
getTimeRangeUrl,
{
throws: new Error('Internal Server Error'),
},
{ overwriteRoutes: true },
{ name: getTimeRangeUrl },
);
timeRange = await fetchTimeRange('Last day');
expect(timeRange).toEqual({
error: 'Internal Server Error',
});
fetchMock.removeRoute(getTimeRangeUrl);
fetchMock.get(
'glob:*/api/v1/time_range/?q=%27Last+day%27',
getTimeRangeUrl,
{
throws: new Response(JSON.stringify({ statusText: 'Network error' }), {
statusText: 'Network error',
}),
},
{ overwriteRoutes: true },
{ name: getTimeRangeUrl },
);
timeRange = await fetchTimeRange('Last day');
expect(timeRange).toEqual({

View File

@@ -20,13 +20,13 @@
import { validateMaxValue } from '@superset-ui/core';
import './setup';
test('validateInteger returns the warning message if invalid', () => {
test('validateMaxValue returns the warning message if invalid', () => {
expect(validateMaxValue(10.1, 10)).toBeTruthy();
expect(validateMaxValue(1, 0)).toBeTruthy();
expect(validateMaxValue('2', 1)).toBeTruthy();
});
test('validateInteger returns false if the input is valid', () => {
test('validateMaxValue returns false if the input is valid', () => {
expect(validateMaxValue(0, 1)).toBeFalsy();
expect(validateMaxValue(10, 10)).toBeFalsy();
expect(validateMaxValue(undefined, 1)).toBeFalsy();

View File

@@ -36,11 +36,11 @@
"@emotion/styled": "^11.14.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@react-icons/all-files": "^4.1.0",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-controls": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/types": "8.6.14",
"@storybook/addon-actions": "^8.6.15",
"@storybook/addon-controls": "^8.6.15",
"@storybook/addon-links": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/types": "^8.6.15",
"@types/react-loadable": "^5.5.11",
"core-js": "3.48.0",
"gh-pages": "^6.3.0",
@@ -52,19 +52,19 @@
"react-resizable": "^3.1.3"
},
"devDependencies": {
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@storybook/react-webpack5": "8.6.14",
"@storybook/react-webpack5": "^8.6.15",
"babel-loader": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3"
},
"peerDependencies": {
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@superset-ui/core": "*",
"@superset-ui/legacy-plugin-chart-calendar": "*",
"@superset-ui/legacy-plugin-chart-chord": "*",
"@superset-ui/legacy-plugin-chart-country-map": "*",

View File

@@ -74,6 +74,9 @@ export default defineConfig({
viewport: { width: 1280, height: 1024 },
// Accept downloads without prompts (needed for export tests)
acceptDownloads: true,
// Screenshots and videos on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
@@ -117,10 +120,19 @@ export default defineConfig({
// Web server setup - disabled in CI (Flask started separately in workflow)
webServer: process.env.CI
? undefined
: {
command: 'curl -f http://localhost:8088/health',
url: 'http://localhost:8088/health',
reuseExistingServer: true,
timeout: 5000,
},
: (() => {
// Support custom base URL (e.g., http://localhost:9012/app/prefix/)
const baseUrl =
process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
// Extract origin (scheme + host + port) for health check
// Health endpoint is always at /health regardless of app prefix
const healthUrl = new URL('/health', new URL(baseUrl).origin).href;
return {
// Quote URL to prevent shell injection via PLAYWRIGHT_BASE_URL
command: `curl -f '${healthUrl}'`,
url: healthUrl,
reuseExistingServer: true,
timeout: 5000,
};
})(),
});

View File

@@ -0,0 +1,116 @@
/**
* 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 { Locator, Page } from '@playwright/test';
import { Button, Checkbox, Table } from '../core';
const BULK_SELECT_SELECTORS = {
CONTROLS: '[data-test="bulk-select-controls"]',
ACTION: '[data-test="bulk-select-action"]',
} as const;
/**
* BulkSelect component for Superset ListView bulk operations.
* Provides a reusable interface for bulk selection and actions across list pages.
*
* @example
* const bulkSelect = new BulkSelect(page, table);
* await bulkSelect.enable();
* await bulkSelect.selectRow('my-dataset');
* await bulkSelect.selectRow('another-dataset');
* await bulkSelect.clickAction('Delete');
*/
export class BulkSelect {
private readonly page: Page;
private readonly table: Table;
constructor(page: Page, table: Table) {
this.page = page;
this.table = table;
}
/**
* Gets the "Bulk select" toggle button
*/
getToggleButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: 'Bulk select' }),
);
}
/**
* Enables bulk selection mode by clicking the toggle button
*/
async enable(): Promise<void> {
await this.getToggleButton().click();
}
/**
* Gets the checkbox for a row by name
* @param rowName - The name/text identifying the row
*/
getRowCheckbox(rowName: string): Checkbox {
const row = this.table.getRow(rowName);
return new Checkbox(this.page, row.getByRole('checkbox'));
}
/**
* Selects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to select
*/
async selectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).check();
}
/**
* Deselects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to deselect
*/
async deselectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).uncheck();
}
/**
* Gets the bulk select controls container locator (for assertions)
*/
getControls(): Locator {
return this.page.locator(BULK_SELECT_SELECTORS.CONTROLS);
}
/**
* Gets a bulk action button by name
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
*/
getActionButton(actionName: string): Button {
const controls = this.getControls();
return new Button(
this.page,
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickAction(actionName: string): Promise<void> {
await this.getActionButton(actionName).click();
}
}

View File

@@ -16,12 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
@primary-color: #20a7c9;
@info-color: #66bcfe;
@success-color: #59c189;
@processing-color: #66bcfe;
@error-color: #e04355;
@highlight-color: #e04355;
@normal-color: #d9d9d9;
@white: #FFF;
@black: #000;
// ListView-specific Playwright Components for Superset
export { BulkSelect } from './BulkSelect';

View File

@@ -0,0 +1,207 @@
/**
* 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 { Locator, Page } from '@playwright/test';
const ACE_EDITOR_SELECTORS = {
TEXT_INPUT: '.ace_text-input',
TEXT_LAYER: '.ace_text-layer',
CONTENT: '.ace_content',
SCROLLER: '.ace_scroller',
} as const;
/**
* AceEditor component for interacting with Ace Editor instances in Playwright.
* Uses the ace editor API directly for reliable text manipulation.
*/
export class AceEditor {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the editor element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Waits for the ace editor to be fully loaded and ready for interaction.
*/
async waitForReady(): Promise<void> {
// Wait for editor to be attached (outer .ace_editor div may be CSS-hidden)
await this.locator.waitFor({ state: 'attached' });
await this.locator
.locator(ACE_EDITOR_SELECTORS.CONTENT)
.waitFor({ state: 'attached' });
// Wait for window.ace library to be fully loaded (may load async)
await this.page.waitForFunction(
() =>
typeof (window as unknown as { ace?: { edit?: unknown } }).ace?.edit ===
'function',
{ timeout: 10000 },
);
}
/**
* Sets text in the ace editor using the ace API.
* Uses element handle to target the specific editor instance (not global ID lookup).
* @param text - The text to set
*/
async setText(text: string): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(
({ element, value }) => {
const windowWithAce = window as unknown as {
ace?: {
edit(el: Element): {
setValue(v: string, c: number): void;
session: { getUndoManager(): { reset(): void } };
};
};
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
// ace.edit() accepts either an element ID string or the DOM element itself
const editor = windowWithAce.ace.edit(element);
editor.setValue(value, 1);
editor.session.getUndoManager().reset();
},
{ element: elementHandle, value: text },
);
}
/**
* Gets the text content from the ace editor.
* Uses element handle to target the specific editor instance.
* @returns The text content
*/
async getText(): Promise<string> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
return this.page.evaluate(element => {
const windowWithAce = window as unknown as {
ace?: { edit(el: Element): { getValue(): string } };
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
return windowWithAce.ace.edit(element).getValue();
}, elementHandle);
}
/**
* Clears the text in the ace editor.
*/
async clear(): Promise<void> {
await this.setText('');
}
/**
* Appends text to the existing content in the ace editor.
* Uses element handle to target the specific editor instance.
* @param text - The text to append
*/
async appendText(text: string): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(
({ element, value }) => {
const windowWithAce = window as unknown as {
ace?: {
edit(el: Element): {
getValue(): string;
setValue(v: string, c: number): void;
};
};
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
const editor = windowWithAce.ace.edit(element);
const currentText = editor.getValue();
// Only add newline if there's existing text that doesn't already end with one
const needsNewline = currentText && !currentText.endsWith('\n');
const newText = currentText + (needsNewline ? '\n' : '') + value;
editor.setValue(newText, 1);
},
{ element: elementHandle, value: text },
);
}
/**
* Focuses the ace editor.
* Uses element handle to target the specific editor instance.
*/
async focus(): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(element => {
const windowWithAce = window as unknown as {
ace?: { edit(el: Element): { focus(): void } };
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
windowWithAce.ace.edit(element).focus();
}, elementHandle);
}
/**
* Checks if the editor is visible.
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
}

View File

@@ -0,0 +1,95 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Core Checkbox component used in Playwright tests to interact with checkbox
* elements in the Superset UI.
*
* This class wraps a Playwright {@link Locator} pointing to a checkbox input
* and provides convenience methods for common interactions such as checking,
* unchecking, toggling, and asserting checkbox state and visibility.
*
* @example
* const checkbox = new Checkbox(page, page.locator('input[type="checkbox"]'));
* await checkbox.check();
* await expect(await checkbox.isChecked()).toBe(true);
*
* @param page - The Playwright {@link Page} instance associated with the test.
* @param locator - The Playwright {@link Locator} targeting the checkbox element.
*/
export class Checkbox {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, locator: Locator) {
this.page = page;
this.locator = locator;
}
/**
* Gets the checkbox element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Checks the checkbox (ensures it's checked)
*/
async check(): Promise<void> {
await this.locator.check();
}
/**
* Unchecks the checkbox (ensures it's unchecked)
*/
async uncheck(): Promise<void> {
await this.locator.uncheck();
}
/**
* Toggles the checkbox state
*/
async toggle(): Promise<void> {
await this.locator.click();
}
/**
* Checks if the checkbox is checked
*/
async isChecked(): Promise<boolean> {
return this.locator.isChecked();
}
/**
* Checks if the checkbox is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the checkbox is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -0,0 +1,217 @@
/**
* 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 { Locator, Page } from '@playwright/test';
import { TIMEOUT } from '../../utils/constants';
/**
* Menu component for Ant Design dropdown menus.
* Uses hover as primary approach (most natural user interaction).
* Falls back to keyboard navigation, then dispatchEvent if hover fails.
*
* This component handles menu content only - not the trigger that opens the menu.
* The calling page object should open the menu first, then use this component.
*
* @example
* // In a page object
* async selectDownloadOption(optionText: string): Promise<void> {
* await this.openHeaderActionsMenu();
* const menu = new Menu(this.page, '[data-test="header-actions-menu"]');
* await menu.selectSubmenuItem('Download', optionText);
* }
*/
export class Menu {
private readonly page: Page;
private readonly locator: Locator;
private static readonly SELECTORS = {
SUBMENU: '.ant-dropdown-menu-submenu',
SUBMENU_POPUP: '.ant-dropdown-menu-submenu-popup',
SUBMENU_TITLE: '.ant-dropdown-menu-submenu-title',
} as const;
/**
* Ant Design animation delay - allows slide-in animation to complete.
* Without this, elements may be "not stable" and clicks can fail.
*/
private static readonly ANIMATION_DELAY = 150;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Opens a submenu and selects an item within it.
* Uses hover as primary approach, falls back to keyboard then dispatchEvent.
*
* @param submenuText - The text of the submenu to open (e.g., "Download")
* @param itemText - The text of the item to select (e.g., "Export YAML")
* @param options - Optional timeout settings
*/
async selectSubmenuItem(
submenuText: string,
itemText: string,
options?: { timeout?: number },
): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
// Try hover first (most natural user interaction)
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
// Fallback to keyboard navigation
if (!popup) {
popup = await this.openSubmenuWithKeyboard(
submenuText,
itemText,
timeout,
);
}
// Last resort: dispatchEvent
if (!popup) {
popup = await this.openSubmenuWithDispatchEvent(
submenuText,
itemText,
timeout,
);
}
if (!popup) {
throw new Error(
`Failed to open submenu "${submenuText}". Tried hover, keyboard, and dispatchEvent.`,
);
}
// Use dispatchEvent instead of click to bypass viewport and pointer interception
// issues. Ant Design renders submenu popups in a portal that can be positioned
// outside the viewport or behind chart content (e.g., large tables with z-index).
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
}
/**
* Opens a submenu using native Playwright hover.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithHover(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.hover();
// Find the popup that contains the expected item (scopes to correct popup)
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
// Allow Ant Design's slide-in animation to complete before clicking.
// Without this, the element may be "not stable" and clicks can fail.
await this.page.waitForTimeout(Menu.ANIMATION_DELAY);
return popup;
} catch {
return null;
}
}
/**
* Opens a submenu using keyboard navigation.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithKeyboard(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.focus();
await this.page.keyboard.press('ArrowRight');
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
} catch {
return null;
}
}
/**
* Opens a submenu using dispatchEvent to trigger mouseover/mouseenter.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithDispatchEvent(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.evaluate(el => {
el.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
view: window,
}),
);
el.dispatchEvent(
new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window,
}),
);
});
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
} catch {
return null;
}
}
/**
* Gets the submenu title element for a submenu containing the given text.
*/
private getSubmenuTitle(submenuText: string): Locator {
return this.locator
.locator(Menu.SELECTORS.SUBMENU)
.filter({ hasText: submenuText })
.locator(Menu.SELECTORS.SUBMENU_TITLE);
}
}

View File

@@ -0,0 +1,187 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Ant Design Select component selectors
*/
const SELECT_SELECTORS = {
DROPDOWN: '.ant-select-dropdown',
OPTION: '.ant-select-item-option',
SEARCH_INPUT: '.ant-select-selection-search-input',
CLEAR: '.ant-select-clear',
} as const;
/**
* Select component for Ant Design Select/Combobox interactions.
*/
export class Select {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Creates a Select from a combobox role with the given accessible name
* @param page - The Playwright page
* @param name - The accessible name (aria-label or placeholder text)
*/
static fromRole(page: Page, name: string): Select {
const locator = page.getByRole('combobox', { name });
return new Select(page, locator);
}
/**
* Gets the select element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Opens the dropdown, types to filter, and selects an option.
* Handles cases where the option may not be initially visible in the dropdown.
* Waits for dropdown to close after selection to avoid stale dropdowns.
* @param optionText - The text of the option to select
*/
async selectOption(optionText: string): Promise<void> {
await this.open();
await this.type(optionText);
await this.clickOption(optionText);
// Wait for dropdown to close to avoid multiple visible dropdowns
await this.waitForDropdownClose();
}
/**
* Waits for dropdown to close after selection
* This prevents strict mode violations when multiple selects are used sequentially
*/
private async waitForDropdownClose(): Promise<void> {
// Wait for dropdown to actually close (become hidden)
await this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last()
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(error => {
// Only ignore TimeoutError (dropdown may already be closed); re-throw others
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
});
}
/**
* Opens the dropdown
*/
async open(): Promise<void> {
await this.locator.click();
}
/**
* Clicks an option in an already-open dropdown by its text content.
* Uses selector-based approach matching Cypress patterns.
* Handles multiple dropdowns by targeting only visible, non-hidden ones.
* @param optionText - The text of the option to click (partial match for filtered results)
*/
async clickOption(optionText: string): Promise<void> {
// Target visible dropdown (excludes hidden ones via :not(.ant-select-dropdown-hidden))
// Use .last() in case multiple dropdowns exist - the most recent one is what we want
const dropdown = this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last();
await dropdown.waitFor({ state: 'visible' });
// Find option by text content - use partial match since filtered results may have prefixes
// (e.g., searching for 'main' shows 'examples.main', 'system.main')
// First try exact match, fall back to partial match
const exactOption = dropdown
.locator(SELECT_SELECTORS.OPTION)
.getByText(optionText, { exact: true });
if ((await exactOption.count()) > 0) {
await exactOption.click();
} else {
// Fall back to first option containing the text
const partialOption = dropdown
.locator(SELECT_SELECTORS.OPTION)
.filter({ hasText: optionText })
.first();
await partialOption.click();
}
}
/**
* Closes the dropdown by pressing Escape
*/
async close(): Promise<void> {
await this.page.keyboard.press('Escape');
}
/**
* Types into the select to filter options (assumes dropdown is open)
* @param text - The text to type
*/
async type(text: string): Promise<void> {
// Find the actual search input inside the select component
const searchInput = this.locator.locator(SELECT_SELECTORS.SEARCH_INPUT);
try {
// Wait for search input in case dropdown is still rendering
await searchInput.first().waitFor({ state: 'attached', timeout: 1000 });
await searchInput.first().fill(text);
} catch (error) {
// Only handle TimeoutError (search input not found); re-throw other errors
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
// Fallback: locator might be the input itself (e.g., from getByRole('combobox'))
await this.locator.fill(text);
}
}
/**
* Clears the current selection
*/
async clear(): Promise<void> {
await this.locator.clear();
}
/**
* Checks if the select is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the select is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -0,0 +1,75 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Tabs component for Ant Design tab navigation.
*/
export class Tabs {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, locator?: Locator) {
this.page = page;
// Default to the tablist role if no specific locator provided
this.locator = locator ?? page.getByRole('tablist');
}
/**
* Gets the tablist element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Gets a tab by name, scoped to this tablist's container
* @param tabName - The name/label of the tab
*/
getTab(tabName: string): Locator {
return this.locator.getByRole('tab', { name: tabName });
}
/**
* Clicks a tab by name
* @param tabName - The name/label of the tab to click
*/
async clickTab(tabName: string): Promise<void> {
await this.getTab(tabName).click();
}
/**
* Gets the tab panel content for a given tab
* @param tabName - The name/label of the tab
*/
getTabPanel(tabName: string): Locator {
return this.page.getByRole('tabpanel', { name: tabName });
}
/**
* Checks if a tab is selected
* @param tabName - The name/label of the tab
*/
async isSelected(tabName: string): Promise<boolean> {
const tab = this.getTab(tabName);
const ariaSelected = await tab.getAttribute('aria-selected');
return ariaSelected === 'true';
}
}

View File

@@ -0,0 +1,109 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Playwright helper for interacting with HTML {@link HTMLTextAreaElement | `<textarea>`} elements.
*
* This component wraps a Playwright {@link Locator} and provides convenience methods for
* filling, clearing, and reading the value of a textarea without having to work with
* locators directly.
*
* Typical usage:
* ```ts
* const textarea = new Textarea(page, 'textarea[name="description"]');
* await textarea.fill('Some multi-line text');
* const value = await textarea.getValue();
* ```
*
* You can also construct an instance from the `name` attribute:
* ```ts
* const textarea = Textarea.fromName(page, 'description');
* await textarea.clear();
* ```
*/
export class Textarea {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Creates a Textarea from a name attribute
* @param page - The Playwright page
* @param name - The name attribute value
*/
static fromName(page: Page, name: string): Textarea {
const locator = page.locator(`textarea[name="${name}"]`);
return new Textarea(page, locator);
}
/**
* Gets the textarea element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Fills the textarea with text (clears existing content)
* @param text - The text to fill
*/
async fill(text: string): Promise<void> {
await this.locator.fill(text);
}
/**
* Clears the textarea content
*/
async clear(): Promise<void> {
await this.locator.clear();
}
/**
* Gets the current value of the textarea
*/
async getValue(): Promise<string> {
return this.locator.inputValue();
}
/**
* Checks if the textarea is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the textarea is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -18,8 +18,15 @@
*/
// Core Playwright Components for Superset
export { AceEditor } from './AceEditor';
export { Button } from './Button';
export { Checkbox } from './Checkbox';
export { Form } from './Form';
export { Input } from './Input';
export { Menu } from './Menu';
export { Modal } from './Modal';
export { Select } from './Select';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { Textarea } from './Textarea';
export { Toast } from './Toast';

View File

@@ -0,0 +1,75 @@
/**
* 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 { Page, Locator } from '@playwright/test';
import { Modal } from '../core/Modal';
/**
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
*/
export class ConfirmDialog extends Modal {
private readonly specificLocator: Locator;
constructor(page: Page, dialogName = 'Confirm save') {
super(page);
// Use getByRole with specific name to avoid strict mode violations
// when multiple dialogs are open (e.g., Edit Dataset modal + Confirm save dialog)
this.specificLocator = page.getByRole('dialog', { name: dialogName });
}
/**
* Override element getter to use specific locator
*/
override get element(): Locator {
return this.specificLocator;
}
/**
* Clicks the OK button to confirm.
* @param options.timeout - If provided, silently returns if dialog doesn't appear
* within timeout. If not provided, waits indefinitely (strict mode).
*/
async clickOk(options?: { timeout?: number }): Promise<void> {
try {
await this.element.waitFor({
state: 'visible',
timeout: options?.timeout,
});
await this.clickFooterButton('OK');
await this.waitForHidden();
} catch (error) {
// Only swallow TimeoutError when timeout was explicitly provided
if (options?.timeout !== undefined) {
if (error instanceof Error && error.name === 'TimeoutError') {
return;
}
}
throw error;
}
}
/**
* Clicks the Cancel button to dismiss
*/
async clickCancel(): Promise<void> {
await this.clickFooterButton('Cancel');
}
}

View File

@@ -55,7 +55,10 @@ export class DuplicateDatasetModal extends Modal {
datasetName: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.nameInput.fill(datasetName, options);
const input = this.nameInput.element;
// Clear existing text then fill (fill() clears first, but explicit clear is more reliable)
await input.clear();
await input.fill(datasetName, options);
}
/**

View File

@@ -0,0 +1,189 @@
/**
* 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 { Locator, Page } from '@playwright/test';
import { Input, Modal, Tabs, AceEditor } from '../core';
/**
* Edit Dataset Modal component (DatasourceModal).
* Used for editing dataset properties like description, metrics, columns, etc.
* Uses specific dialog name to avoid strict mode violations when multiple dialogs are open.
*/
export class EditDatasetModal extends Modal {
private static readonly SELECTORS = {
NAME_INPUT: '[data-test="inline-name"]',
LOCK_ICON: '[data-test="lock"]',
UNLOCK_ICON: '[data-test="unlock"]',
};
private readonly tabs: Tabs;
private readonly specificLocator: Locator;
constructor(page: Page) {
super(page);
// Use getByRole with specific name to target Edit Dataset dialog
// The dialog has aria-labelledby that resolves to "edit Edit Dataset"
this.specificLocator = page.getByRole('dialog', { name: /edit.*dataset/i });
// Scope tabs to modal's tablist to avoid matching tablists elsewhere on page
this.tabs = new Tabs(page, this.specificLocator.getByRole('tablist'));
}
/**
* Override element getter to use specific locator
*/
override get element(): Locator {
return this.specificLocator;
}
/**
* Click the Save button to save changes
*/
async clickSave(): Promise<void> {
await this.clickFooterButton('Save');
}
/**
* Click the Cancel button to discard changes
*/
async clickCancel(): Promise<void> {
await this.clickFooterButton('Cancel');
}
/**
* Click the lock icon to enable edit mode
* The modal starts in read-only mode and requires clicking the lock to edit
*/
async enableEditMode(): Promise<void> {
const lockButton = this.body.locator(EditDatasetModal.SELECTORS.LOCK_ICON);
await lockButton.click();
}
/**
* Gets the dataset name input component
*/
private get nameInput(): Input {
return new Input(
this.page,
this.body.locator(EditDatasetModal.SELECTORS.NAME_INPUT),
);
}
/**
* Fill in the dataset name field
* Note: Call enableEditMode() first if the modal is in read-only mode
* @param name - The new dataset name
*/
async fillName(name: string): Promise<void> {
await this.nameInput.fill(name);
}
/**
* Navigate to a specific tab in the modal
* @param tabName - The name of the tab (e.g., 'Source', 'Metrics', 'Columns')
*/
async clickTab(tabName: string): Promise<void> {
await this.tabs.clickTab(tabName);
}
/**
* Navigate to the Settings tab
*/
async clickSettingsTab(): Promise<void> {
await this.tabs.clickTab('Settings');
}
/**
* Navigate to the Columns tab.
* Uses regex to avoid matching "Calculated columns" tab, scoped to modal.
*/
async clickColumnsTab(): Promise<void> {
// Use regex starting with "Columns" to avoid matching "Calculated columns"
// Scope to modal element to avoid matching tabs elsewhere on page
await this.element.getByRole('tab', { name: /^Columns/ }).click();
}
/**
* Gets the description Ace Editor component (Settings tab).
* The Description button and ace-editor are in the same form item.
*/
private get descriptionEditor(): AceEditor {
// Use tabpanel role with name "Settings" for more reliable lookup
const settingsPanel = this.element.getByRole('tabpanel', {
name: 'Settings',
});
// Find the form item that contains the Description button
const descriptionFormItem = settingsPanel
.locator('.ant-form-item')
.filter({
has: this.page.getByRole('button', {
name: 'Description',
exact: true,
}),
})
.first();
// The ace-editor has class .ace_editor within the form item
const editorElement = descriptionFormItem.locator('.ace_editor');
return new AceEditor(this.page, editorElement);
}
/**
* Fill the dataset description field (Settings tab).
* @param description - The description text to set
*/
async fillDescription(description: string): Promise<void> {
await this.descriptionEditor.setText(description);
}
/**
* Expand a column row by column name.
* Uses exact cell match to avoid false positives with short names like "ds".
* @param columnName - The name of the column to expand
* @returns The row locator for scoped selector access
*/
async expandColumn(columnName: string): Promise<Locator> {
// Find cell with exact column name text, then derive row from that cell
const cell = this.body.getByRole('cell', { name: columnName, exact: true });
const row = cell.locator('xpath=ancestor::tr[1]');
await row.getByRole('button', { name: /expand row/i }).click();
return row;
}
/**
* Fill column datetime format for a given column.
* Expands the column row and fills the date format input.
* Note: Expanded content appears in a sibling row, so we scope to modal body.
* @param columnName - The name of the column to edit
* @param format - The python date format string (e.g., '%Y-%m-%d')
*/
async fillColumnDateFormat(
columnName: string,
format: string,
): Promise<void> {
await this.expandColumn(columnName);
// Expanded content appears in a sibling row, not nested inside the original row.
// Use modal body scope with placeholder selector to find the datetime format input.
const dateFormatInput = new Input(
this.page,
this.body.getByPlaceholder('%Y-%m-%d'),
);
await dateFormatInput.element.waitFor({ state: 'visible' });
await dateFormatInput.clear();
await dateFormatInput.fill(format);
}
}

View File

@@ -0,0 +1,73 @@
/**
* 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 { Modal, Input } from '../core';
/**
* Import dataset modal for uploading dataset export files.
* Handles file upload, overwrite confirmation, and import submission.
*/
export class ImportDatasetModal extends Modal {
private static readonly SELECTORS = {
FILE_INPUT: '[data-test="model-file-input"]',
OVERWRITE_INPUT: '[data-test="overwrite-modal-input"]',
};
/**
* Upload a file to the import modal
* @param filePath - Absolute path to the file to upload
*/
async uploadFile(filePath: string): Promise<void> {
await this.page
.locator(ImportDatasetModal.SELECTORS.FILE_INPUT)
.setInputFiles(filePath);
}
/**
* Fill the overwrite confirmation input (only needed if dataset exists)
*/
async fillOverwriteConfirmation(): Promise<void> {
const input = new Input(
this.page,
this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT),
);
await input.fill('OVERWRITE');
}
/**
* Get the overwrite confirmation input locator
*/
getOverwriteInput() {
return this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT);
}
/**
* Check if overwrite confirmation is visible
*/
async isOverwriteVisible(): Promise<boolean> {
return this.getOverwriteInput().isVisible();
}
/**
* Click the Import button in the footer
*/
async clickImport(): Promise<void> {
await this.clickFooterButton('Import');
}
}

View File

@@ -20,3 +20,4 @@
// Specific modal implementations
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';

View File

@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Response, APIResponse } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Common interface for response types with status() method.
* Supports both Response (network interception) and APIResponse (page.request API).
*/
type ResponseLike = Response | APIResponse;
/**
* Verify response has exact status code
* @param response - Playwright Response or APIResponse object
* @param expected - Expected status code
* @returns The response for chaining
*/
export function expectStatus<T extends ResponseLike>(
response: T,
expected: number,
): T {
expect(
response.status(),
`Expected status ${expected}, got ${response.status()}`,
).toBe(expected);
return response;
}
/**
* Verify response status code is one of the expected values
* @param response - Playwright Response or APIResponse object
* @param expected - Array of acceptable status codes
* @returns The response for chaining
*/
export function expectStatusOneOf<T extends ResponseLike>(
response: T,
expected: number[],
): T {
expect(
expected,
`Expected status to be one of ${expected.join(', ')}, got ${response.status()}`,
).toContain(response.status());
return response;
}

View File

@@ -18,12 +18,33 @@
*/
import { Page, APIResponse } from '@playwright/test';
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
import rison from 'rison';
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
const ENDPOINTS = {
DATABASE: 'api/v1/database/',
} as const;
/**
* TypeScript interface for database API response
*/
export interface DatabaseResult {
id: number;
database_name: string;
/** Optional - list API masks this for security, only detail API returns it */
sqlalchemy_uri?: string;
backend?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
supports_dynamic_catalog?: boolean;
supports_file_upload?: boolean;
supports_oauth2?: boolean;
};
extra?: string;
expose_in_sqllab?: boolean;
impersonate_user?: boolean;
}
/**
* TypeScript interface for database creation API payload
* Provides compile-time safety for required fields
@@ -31,6 +52,7 @@ const ENDPOINTS = {
export interface DatabaseCreatePayload {
database_name: string;
engine: string;
sqlalchemy_uri?: string;
configuration_method?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
@@ -77,3 +99,53 @@ export async function apiDeleteDatabase(
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}
/**
* GET request to fetch a database's details
* @param page - Playwright page instance (provides authentication context)
* @param databaseId - ID of the database to fetch
* @returns API response with database details
*/
export async function apiGetDatabase(
page: Page,
databaseId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}
/**
* Get a database by its name
* @param page - Playwright page instance (provides authentication context)
* @param databaseName - The database_name to search for
* @returns Database object if found, null if not found
*/
export async function getDatabaseByName(
page: Page,
databaseName: string,
): Promise<DatabaseResult | null> {
const filter = {
filters: [
{
col: 'database_name',
opr: 'eq',
value: databaseName,
},
],
};
const queryParam = rison.encode(filter);
const response = await apiGet(page, `${ENDPOINTS.DATABASE}?q=${queryParam}`, {
failOnStatusCode: false,
});
if (!response.ok()) {
return null;
}
const body = await response.json();
if (body.result && body.result.length > 0) {
return body.result[0] as DatabaseResult;
}
return null;
}

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