Compare commits

...

74 Commits

Author SHA1 Message Date
Evan Rusackas
66dca73591 fix(deps): sync deck.gl/luma.gl overrides with plugin dependency versions
Dependabot bumped deck.gl plugin dependencies to ~9.2.5/~9.2.6 across
several PRs, but the root package.json overrides section still pinned
all deck.gl and luma.gl packages to ~9.2.2. This forced older
luma.gl/shadertools (9.2.2) to be used with newer deck.gl aggregation
layers (9.2.5), causing DECKGL_FILTER_COLOR shader compilation errors
at runtime on the ScreenGrid, Grid, and Hexagon layers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 17:27:49 -08: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
396 changed files with 28420 additions and 4292 deletions

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

@@ -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

@@ -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,7 @@ class CeleryConfig:
CELERY_CONFIG = CeleryConfig
FEATURE_FLAGS = {"ALERT_REPORTS": True}
FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True}
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,28 +52,26 @@
"@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.30001768",
"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",
@@ -88,8 +83,8 @@
"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

@@ -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.2.0",
"immer": "^11.1.3",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -179,9 +179,10 @@
"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",
@@ -216,12 +217,13 @@
"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",
@@ -231,12 +233,12 @@
"@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",
@@ -244,18 +246,18 @@
"@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 +274,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.1.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -286,6 +288,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 +297,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 +325,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 +362,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,21 +390,21 @@
"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",
"@deck.gl/extensions": "~9.2.2",
"@deck.gl/geo-layers": "~9.2.2",
"@deck.gl/layers": "~9.2.2",
"@deck.gl/aggregation-layers": "~9.2.5",
"@deck.gl/core": "~9.2.5",
"@deck.gl/extensions": "~9.2.5",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mesh-layers": "~9.2.2",
"@deck.gl/react": "~9.2.2",
"@deck.gl/widgets": "~9.2.2",
"@luma.gl/constants": "~9.2.2",
"@luma.gl/core": "~9.2.2",
"@luma.gl/engine": "~9.2.2",
"@luma.gl/shadertools": "~9.2.2",
"@luma.gl/webgl": "~9.2.2"
"@deck.gl/react": "~9.2.5",
"@deck.gl/widgets": "~9.2.5",
"@luma.gl/constants": "~9.2.6",
"@luma.gl/core": "~9.2.6",
"@luma.gl/engine": "~9.2.6",
"@luma.gl/shadertools": "~9.2.6",
"@luma.gl/webgl": "~9.2.6"
},
"readme": "ERROR: No README data found!",
"scarfSettings": {

View File

@@ -13,7 +13,7 @@
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@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.1.0",
"@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

@@ -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",
@@ -53,18 +53,18 @@
},
"devDependencies": {
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@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;
}

View File

@@ -20,9 +20,13 @@
import { Page, APIResponse } from '@playwright/test';
import rison from 'rison';
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
import { getDatabaseByName } from './database';
export const ENDPOINTS = {
DATASET: 'api/v1/dataset/',
DATASET_EXPORT: 'api/v1/dataset/export/',
DATASET_DUPLICATE: 'api/v1/dataset/duplicate',
DATASET_IMPORT: 'api/v1/dataset/import/',
} as const;
/**
@@ -37,12 +41,12 @@ export interface DatasetCreatePayload {
}
/**
* TypeScript interface for virtual dataset creation API payload
* Virtual datasets are SQL-based and support the Duplicate action in UI
* TypeScript interface for virtual dataset creation API payload.
* Virtual datasets are defined by SQL queries rather than physical tables.
*/
export interface VirtualDatasetCreatePayload {
database: number;
schema: string;
schema: string | null;
table_name: string;
sql: string;
owners?: number[];
@@ -55,8 +59,8 @@ export interface VirtualDatasetCreatePayload {
export interface DatasetResult {
id: number;
table_name: string;
sql?: string;
schema?: string;
sql?: string | null;
schema?: string | null;
database: {
id: number;
database_name: string;
@@ -79,11 +83,11 @@ export async function apiPostDataset(
}
/**
* POST request to create a virtual (SQL-based) dataset
* Virtual datasets support the Duplicate action in the UI
* POST request to create a virtual dataset with SQL.
* Use expectStatusOneOf() on the response and handle both result.id and id shapes.
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Virtual dataset config (database, schema, table_name, sql)
* @returns API response from dataset creation
* @param requestBody - Virtual dataset configuration (database, schema, table_name, sql)
* @returns API response from virtual dataset creation
*/
export async function apiPostVirtualDataset(
page: Page,
@@ -96,16 +100,27 @@ export async function apiPostVirtualDataset(
* Creates a simple virtual dataset for testing purposes
* @param page - Playwright page instance
* @param name - Name for the virtual dataset
* @param databaseId - ID of the database to use (defaults to 1 for examples db)
* @param databaseId - ID of the database to use (looks up 'examples' DB if not provided)
* @returns The created dataset ID, or null on failure
*/
export async function createTestVirtualDataset(
page: Page,
name: string,
databaseId = 1,
databaseId?: number,
): Promise<number | null> {
// Look up examples database if no ID provided
let dbId = databaseId;
if (dbId === undefined) {
const examplesDb = await getDatabaseByName(page, 'examples');
if (!examplesDb?.id) {
console.warn('Failed to find examples database');
return null;
}
dbId = examplesDb.id;
}
const response = await apiPostVirtualDataset(page, {
database: databaseId,
database: dbId,
schema: '',
table_name: name,
sql: "SELECT 1 as id, 'test' as name",
@@ -118,7 +133,8 @@ export async function createTestVirtualDataset(
}
const body = await response.json();
return body.id ?? null;
// Handle both response shapes: { id } or { result: { id } }
return body.result?.id ?? body.id ?? null;
}
/**
@@ -186,3 +202,30 @@ export async function apiDeleteDataset(
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}
/**
* Duplicate a dataset via the API
* @param page - Playwright page instance (provides authentication context)
* @param datasetId - ID of the dataset to duplicate
* @param newName - Name for the duplicated dataset
* @returns Object containing the new dataset's ID (use apiGetDataset for full details)
*/
export async function duplicateDataset(
page: Page,
datasetId: number,
newName: string,
): Promise<{ id: number }> {
const response = await apiPost(page, `${ENDPOINTS.DATASET}duplicate`, {
base_model_id: datasetId,
table_name: newName,
});
const body = await response.json();
// Normalize: API may return id at top level or inside result
const resolvedId = body.result?.id ?? body.id;
if (!resolvedId) {
throw new Error(
`Duplicate dataset API returned no id. Response: ${JSON.stringify(body)}`,
);
}
return { id: resolvedId };
}

View File

@@ -0,0 +1,145 @@
/**
* 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 { Page, Response } from '@playwright/test';
/**
* HTTP methods enum for consistency
*/
export const HTTP_METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
DELETE: 'DELETE',
PATCH: 'PATCH',
} as const;
type HttpMethod = (typeof HTTP_METHODS)[keyof typeof HTTP_METHODS];
/**
* Options for waitFor* functions
*/
interface WaitForResponseOptions {
/** Optional timeout in milliseconds */
timeout?: number;
/** Match against URL pathname suffix instead of full URL includes (default: false) */
pathMatch?: boolean;
}
/**
* Normalize a path by removing trailing slashes
*/
function normalizePath(path: string): string {
return path.replace(/\/+$/, '');
}
/**
* Check if a URL matches a pattern
* - String + pathMatch: pathname.endsWith(pattern) with trailing slash normalization
* - String: url.includes(pattern)
* - RegExp: pattern.test(url)
*/
function matchUrl(
url: string,
pattern: string | RegExp,
pathMatch?: boolean,
): boolean {
if (typeof pattern === 'string') {
if (pathMatch) {
const pathname = normalizePath(new URL(url).pathname);
const normalizedPattern = normalizePath(pattern);
return pathname.endsWith(normalizedPattern);
}
return url.includes(pattern);
}
return pattern.test(url);
}
/**
* Generic helper to wait for a response matching URL pattern and HTTP method
*/
function waitForResponse(
page: Page,
urlPattern: string | RegExp,
method: HttpMethod,
options?: WaitForResponseOptions,
): Promise<Response> {
const { pathMatch, ...waitOptions } = options ?? {};
return page.waitForResponse(
response =>
matchUrl(response.url(), urlPattern, pathMatch) &&
response.request().method() === method,
waitOptions,
);
}
/**
* Wait for a GET response matching the URL pattern
*/
export function waitForGet(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.GET, options);
}
/**
* Wait for a POST response matching the URL pattern
*/
export function waitForPost(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.POST, options);
}
/**
* Wait for a PUT response matching the URL pattern
*/
export function waitForPut(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.PUT, options);
}
/**
* Wait for a DELETE response matching the URL pattern
*/
export function waitForDelete(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.DELETE, options);
}
/**
* Wait for a PATCH response matching the URL pattern
*/
export function waitForPatch(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.PATCH, options);
}

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
// Base fixture with test asset cleanup
export { test as testWithAssets, expect, type TestAssets } from './testAssets';

View File

@@ -0,0 +1,68 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { test as base } from '@playwright/test';
import { apiDeleteDataset } from '../api/dataset';
import { apiDeleteDatabase } from '../api/database';
/**
* Test asset tracker for automatic cleanup after each test.
* Inspired by Cypress's cleanDashboards/cleanCharts pattern.
*/
export interface TestAssets {
trackDataset(id: number): void;
trackDatabase(id: number): void;
}
export const test = base.extend<{ testAssets: TestAssets }>({
testAssets: async ({ page }, use) => {
// Use Set to de-dupe IDs (same resource may be tracked multiple times)
const datasetIds = new Set<number>();
const databaseIds = new Set<number>();
await use({
trackDataset: id => datasetIds.add(id),
trackDatabase: id => databaseIds.add(id),
});
// Cleanup: Delete datasets FIRST (they reference databases)
// Then delete databases. Use failOnStatusCode: false for tolerance.
await Promise.all(
[...datasetIds].map(id =>
apiDeleteDataset(page, id, { failOnStatusCode: false }).catch(error => {
console.warn(`[testAssets] Failed to cleanup dataset ${id}:`, error);
}),
),
);
await Promise.all(
[...databaseIds].map(id =>
apiDeleteDatabase(page, id, { failOnStatusCode: false }).catch(
error => {
console.warn(
`[testAssets] Failed to cleanup database ${id}:`,
error,
);
},
),
),
);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,138 @@
/**
* 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 { expect, Locator, Page } from '@playwright/test';
import { Button, Select } from '../components/core';
/**
* Chart Creation Page object for the "Create a new chart" wizard.
* This page appears after creating a dataset via the wizard.
*/
export class ChartCreationPage {
readonly page: Page;
private static readonly SELECTORS = {
VIZ_GALLERY: '.viz-gallery',
VIZ_TYPE_ITEM: '[data-test="viz-type-gallery__item"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Gets the dataset selector container (includes the displayed selection value)
*/
getDatasetSelectContainer(): Locator {
return this.page.getByLabel('Dataset', { exact: false }).first();
}
/**
* Gets the dataset selector for interactions
*/
getDatasetSelect(): Select {
return new Select(
this.page,
this.page.getByRole('combobox', { name: /dataset/i }),
);
}
/**
* Gets the visualization gallery container
*/
getVizGallery(): Locator {
return this.page.locator(ChartCreationPage.SELECTORS.VIZ_GALLERY);
}
/**
* Gets the "Create new chart" button
*/
getCreateChartButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: /create new chart/i }),
);
}
/**
* Navigate to the chart creation page
*/
async goto(): Promise<void> {
await this.page.goto('chart/add');
}
/**
* Wait for the page to load (dataset selector visible)
*/
async waitForPageLoad(): Promise<void> {
await expect(this.getDatasetSelect().element).toBeVisible({
timeout: 10000,
});
}
/**
* Select a dataset from the dropdown
* @param datasetName - The name of the dataset to select
*/
async selectDataset(datasetName: string): Promise<void> {
await this.getDatasetSelect().selectOption(datasetName);
}
/**
* Select a visualization type from the gallery
* @param vizType - The visualization type to select (e.g., 'Table', 'Bar Chart')
*/
async selectVizType(vizType: string): Promise<void> {
const vizGallery = this.getVizGallery();
await expect(vizGallery).toBeVisible();
// Button names in the gallery are duplicated (e.g., "Table Table", "Bar Chart Bar Chart")
// because they include both the image alt text and the label text.
// Use exact match with the duplicated pattern to avoid matching similar names.
const vizTypeItem = vizGallery.getByRole('button', {
name: `${vizType} ${vizType}`,
exact: true,
});
await vizTypeItem.click();
}
/**
* Click the "Create new chart" button to navigate to Explore
*/
async clickCreateNewChart(): Promise<void> {
await this.getCreateChartButton().click();
}
/**
* Verify the dataset is pre-selected (shown in the selector)
* @param datasetName - The expected dataset name
*/
async expectDatasetSelected(datasetName: string): Promise<void> {
// For Ant Design selects, the selected value is displayed in a sibling element,
// not in the combobox input. Check the container for the displayed text.
await expect(this.getDatasetSelectContainer()).toContainText(datasetName);
}
/**
* Check if the "Create new chart" button is enabled
*/
async isCreateButtonEnabled(): Promise<boolean> {
return this.getCreateChartButton().isEnabled();
}
}

View File

@@ -0,0 +1,138 @@
/**
* 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 } from '@playwright/test';
import { Button, Select } from '../components/core';
/**
* Create Dataset Page object for the dataset creation wizard.
*/
export class CreateDatasetPage {
readonly page: Page;
/**
* Data-test selectors for the create dataset form elements.
* Using data-test attributes avoids strict mode violations with multiple selects.
*/
private static readonly SELECTORS = {
DATABASE: '[data-test="select-database"]',
SCHEMA: '[data-test="Select schema or type to search schemas"]',
TABLE: '[data-test="Select table or type to search tables"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Gets the database selector using data-test attribute
*/
getDatabaseSelect(): Select {
return new Select(this.page, CreateDatasetPage.SELECTORS.DATABASE);
}
/**
* Gets the schema selector using data-test attribute
*/
getSchemaSelect(): Select {
return new Select(this.page, CreateDatasetPage.SELECTORS.SCHEMA);
}
/**
* Gets the table selector using data-test attribute
*/
getTableSelect(): Select {
return new Select(this.page, CreateDatasetPage.SELECTORS.TABLE);
}
/**
* Gets the create and explore button
*/
getCreateAndExploreButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: /Create and explore dataset/i }),
);
}
/**
* Navigate to the create dataset page
*/
async goto(): Promise<void> {
await this.page.goto('dataset/add/');
}
/**
* Select a database from the dropdown
* @param databaseName - The name of the database to select
*/
async selectDatabase(databaseName: string): Promise<void> {
await this.getDatabaseSelect().selectOption(databaseName);
}
/**
* Select a schema from the dropdown
* @param schemaName - The name of the schema to select
*/
async selectSchema(schemaName: string): Promise<void> {
await this.getSchemaSelect().selectOption(schemaName);
}
/**
* Select a table from the dropdown
* @param tableName - The name of the table to select
*/
async selectTable(tableName: string): Promise<void> {
await this.getTableSelect().selectOption(tableName);
}
/**
* Click the "Create dataset" button (without exploring)
* Uses the dropdown menu to select "Create dataset" option
*/
async clickCreateDataset(): Promise<void> {
// Find the "Create and explore dataset" button, then its sibling dropdown trigger
// This avoids ambiguity if other "down" buttons exist on the page
const mainButton = this.page.getByRole('button', {
name: /Create and explore dataset/i,
});
// The dropdown trigger is in the same button group, find it relative to main button
const dropdownTrigger = mainButton
.locator('xpath=following-sibling::button')
.first();
await dropdownTrigger.click();
// Click "Create dataset" option from the dropdown menu
await this.page.getByText('Create dataset', { exact: true }).click();
}
/**
* Click the "Create and explore dataset" button
*/
async clickCreateAndExploreDataset(): Promise<void> {
await this.getCreateAndExploreButton().click();
}
/**
* Wait for the page to load
*/
async waitForPageLoad(): Promise<void> {
await this.getDatabaseSelect().element.waitFor({ state: 'visible' });
}
}

View File

@@ -18,6 +18,7 @@
*/
import { Page, Download } from '@playwright/test';
import { Menu } from '../components/core';
import { TIMEOUT } from '../utils/constants';
/**
@@ -54,7 +55,7 @@ export class DashboardPage {
}
/**
* Wait for the dashboard to load
* Wait for the dashboard header to be visible.
*/
async waitForLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
@@ -63,6 +64,35 @@ export class DashboardPage {
});
}
/**
* Wait for all charts on the dashboard to finish loading.
* Waits until no loading indicators are visible on the page.
*/
async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
// Use browser-context evaluation to check visibility directly.
// Loading indicators ([aria-label="Loading"]) may persist in the DOM as hidden
// elements after charts finish loading. This checks that none are currently visible,
// returning immediately when charts are already loaded (no timeout penalty).
await this.page.waitForFunction(
() => {
const loaders = document.querySelectorAll('[aria-label="Loading"]');
if (loaders.length === 0) return true;
return Array.from(loaders).every(el => {
const style = getComputedStyle(el);
return (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
);
});
},
undefined,
{ timeout },
);
}
/**
* Open the dashboard header actions menu (three-dot menu)
*/
@@ -78,33 +108,21 @@ export class DashboardPage {
}
/**
* Hover over the Download submenu to open it (Ant Design submenus open on hover)
* Selects an option from the Download submenu.
* Opens the header actions menu, navigates to Download submenu,
* and clicks the specified option.
*
* @param optionText - The download option to select (e.g., "Export YAML")
*/
async openDownloadMenu(): Promise<void> {
// Find the Download menu item within the header actions menu and hover
const menu = this.page.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU);
await menu.getByText('Download', { exact: true }).hover();
// Wait for Export YAML to become visible (indicates submenu opened)
await this.page.getByText('Export YAML').waitFor({ state: 'visible' });
}
async selectDownloadOption(optionText: string): Promise<Download> {
await this.openHeaderActionsMenu();
/**
* Click "Export YAML" in the download menu
* Returns a Promise that resolves when download starts
*/
async clickExportYaml(): Promise<Download> {
const menu = new Menu(
this.page,
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
);
const downloadPromise = this.page.waitForEvent('download');
await this.page.getByText('Export YAML').click();
return downloadPromise;
}
/**
* Click "Export as Example" in the download menu
* Returns a Promise that resolves when download starts
*/
async clickExportAsExample(): Promise<Download> {
const downloadPromise = this.page.waitForEvent('download');
await this.page.getByText('Export as Example').click();
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
}

View File

@@ -18,7 +18,8 @@
*/
import { Page, Locator } from '@playwright/test';
import { Table } from '../components/core';
import { Button, Table } from '../components/core';
import { BulkSelect } from '../components/ListView';
import { URL } from '../utils/urls';
/**
@@ -27,17 +28,26 @@ import { URL } from '../utils/urls';
export class DatasetListPage {
private readonly page: Page;
private readonly table: Table;
readonly bulkSelect: BulkSelect;
private static readonly SELECTORS = {
DATASET_LINK: '[data-test="internal-link"]',
DELETE_ACTION: '.action-button svg[data-icon="delete"]',
EXPORT_ACTION: '.action-button svg[data-icon="upload"]',
DUPLICATE_ACTION: '.action-button svg[data-icon="copy"]',
} as const;
/**
* Action button names for getByRole('button', { name })
*/
private static readonly ACTION_BUTTONS = {
DELETE: 'delete',
EDIT: 'edit',
EXPORT: 'upload', // Export button uses upload icon
DUPLICATE: 'copy',
} as const;
constructor(page: Page) {
this.page = page;
this.table = new Table(page);
this.bulkSelect = new BulkSelect(page, this.table);
}
/**
@@ -85,10 +95,21 @@ export class DatasetListPage {
* @param datasetName - The name of the dataset to delete
*/
async clickDeleteAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DELETE_ACTION,
);
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DELETE })
.click();
}
/**
* Clicks the edit action button for a dataset
* @param datasetName - The name of the dataset to edit
*/
async clickEditAction(datasetName: string): Promise<void> {
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EDIT })
.click();
}
/**
@@ -96,10 +117,10 @@ export class DatasetListPage {
* @param datasetName - The name of the dataset to export
*/
async clickExportAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.EXPORT_ACTION,
);
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EXPORT })
.click();
}
/**
@@ -107,9 +128,57 @@ export class DatasetListPage {
* @param datasetName - The name of the dataset to duplicate
*/
async clickDuplicateAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DUPLICATE_ACTION,
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DUPLICATE })
.click();
}
/**
* Clicks the "Bulk select" button to enable bulk selection mode
*/
async clickBulkSelectButton(): Promise<void> {
await this.bulkSelect.enable();
}
/**
* Selects a dataset's checkbox in bulk select mode
* @param datasetName - The name of the dataset to select
*/
async selectDatasetCheckbox(datasetName: string): Promise<void> {
await this.bulkSelect.selectRow(datasetName);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
/**
* Gets the "+ Dataset" button for creating new datasets.
* Uses specific selector to avoid matching the "Datasets" nav link.
*/
getAddDatasetButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: /^\+ Dataset$|^plus Dataset$/ }),
);
}
/**
* Clicks the "+ Dataset" button to navigate to create dataset page
*/
async clickAddDataset(): Promise<void> {
await this.getAddDatasetButton().click();
}
/**
* Clicks the import button to open the import modal
*/
async clickImportButton(): Promise<void> {
await this.page.getByTestId('import-button').click();
}
}

View File

@@ -19,6 +19,7 @@
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../../../pages/DashboardPage';
import { Toast } from '../../../components/core';
import { TIMEOUT } from '../../../utils/constants';
/**
@@ -31,74 +32,56 @@ import { TIMEOUT } from '../../../utils/constants';
* Prerequisites:
* - Superset running with example dashboards loaded
* - Admin user authenticated (via global-setup)
*
* SKIP REASON: Ant Design Menu submenu hover behavior is not reliably
* triggered by Playwright. The submenu popup doesn't appear consistently
* when hovering over the Download menu item. This functionality is
* covered by unit tests in DownloadMenuItems.test.tsx.
*
* TODO: Investigate Ant Design Menu triggerSubMenuAction or alternative
* approaches for E2E testing of nested menus.
*/
let dashboardPage: DashboardPage;
const downloads: { delete: () => Promise<void> }[] = [];
test.describe('Dashboard Export', () => {
// Dashboard with multiple charts needs extra time for cold-cache CI runs:
// waitForLoad (10s) + waitForChartsToLoad (15s) + menu + download + toast
test.setTimeout(60_000);
test.describe.skip('Dashboard Export', () => {
test.beforeEach(async ({ page }) => {
dashboardPage = new DashboardPage(page);
// Navigate to World Health dashboard (standard example)
await dashboardPage.gotoBySlug('world_health');
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
// Wait for charts to finish loading - Download menu may be disabled while loading
await dashboardPage.waitForChartsToLoad();
});
test('should download ZIP when clicking Export YAML', async ({ page }) => {
// Open the header actions menu (three-dot menu)
await dashboardPage.openHeaderActionsMenu();
// Open the Download submenu
await dashboardPage.openDownloadMenu();
// Click Export YAML and wait for download
const download = await dashboardPage.clickExportYaml();
// Verify the download
const filename = download.suggestedFilename();
expect(filename).toMatch(/\.zip$/);
test.afterEach(async () => {
// Clean up downloaded files
await Promise.all(downloads.map(d => d.delete().catch(() => {})));
downloads.length = 0;
});
test('should download example bundle when clicking Export as Example', async ({
test('should download ZIP and show success toast when clicking Export YAML', async ({
page,
}) => {
// Open the header actions menu
await dashboardPage.openHeaderActionsMenu();
const toast = new Toast(page);
const download = await dashboardPage.selectDownloadOption('Export YAML');
downloads.push(download);
// Open the Download submenu
await dashboardPage.openDownloadMenu();
// Click Export as Example and wait for download
const download = await dashboardPage.clickExportAsExample();
// Verify the download
const filename = download.suggestedFilename();
expect(filename).toMatch(/_example\.zip$/);
expect(download.suggestedFilename()).toMatch(/\.zip$/);
await expect(toast.getSuccess()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
});
test('should show success toast after Export as Example', async ({
test('should download example bundle and show success toast when clicking Export as Example', async ({
page,
}) => {
// Open the header actions menu
await dashboardPage.openHeaderActionsMenu();
const toast = new Toast(page);
const download =
await dashboardPage.selectDownloadOption('Export as Example');
downloads.push(download);
// Open the Download submenu
await dashboardPage.openDownloadMenu();
// Click Export as Example
await dashboardPage.clickExportAsExample();
// Verify success toast appears
await expect(
page.locator('.ant-message-success, [data-test="toast-success"]'),
).toBeVisible({ timeout: TIMEOUT.API_RESPONSE });
expect(download.suggestedFilename()).toMatch(/_example\.zip$/);
await expect(toast.getSuccess()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
});
});

View File

@@ -0,0 +1,219 @@
/**
* 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 { test, expect } from '../../../helpers/fixtures/testAssets';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import type { Page, TestInfo } from '@playwright/test';
import { ExplorePage } from '../../../pages/ExplorePage';
import { CreateDatasetPage } from '../../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ChartCreationPage } from '../../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../../helpers/api/dataset';
import { waitForPost } from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { apiPostDatabase } from '../../../helpers/api/database';
interface GsheetsSetupResult {
sheetName: string;
dbName: string;
createDatasetPage: CreateDatasetPage;
}
/**
* Sets up gsheets database and navigates to create dataset page.
* Skips test if gsheets connector unavailable (test.skip() throws, so no return).
* @param testInfo - Test info for parallelIndex to avoid name collisions in parallel runs
* @returns Setup result with names and page object
*/
async function setupGsheetsDataset(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
): Promise<GsheetsSetupResult> {
// Public Google Sheet for testing (published to web, no auth required).
// This is a Netflix dataset that is publicly accessible via the Google Visualization API.
// NOTE: This sheet is hosted on an external Google account and is not created by the test itself.
// If this sheet is deleted, its ID changes, or its sharing settings are restricted,
// these tests will start failing when they attempt to create a database pointing at it.
// In that case, create or select a new publicly readable test sheet, update `sheetUrl`
// to use its URL, and update this comment to describe who owns/maintains that sheet
// and the expected access controls (e.g., "anyone with the link can view").
const sheetUrl =
'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303';
// Include parallelIndex to avoid collisions when tests run in parallel
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
const sheetName = `test_netflix_${uniqueSuffix}`;
const dbName = `test_gsheets_db_${uniqueSuffix}`;
// Create a Google Sheets database via API
// The catalog must be in `extra` as JSON with engine_params.catalog format
const catalogDict = { [sheetName]: sheetUrl };
const createDbRes = await apiPostDatabase(page, {
database_name: dbName,
engine: 'gsheets',
sqlalchemy_uri: 'gsheets://',
configuration_method: 'dynamic_form',
expose_in_sqllab: true,
extra: JSON.stringify({
engine_params: {
catalog: catalogDict,
},
}),
});
// Check if gsheets connector is available
if (!createDbRes.ok()) {
const errorBody = await createDbRes.json();
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine')
) {
await test.info().attach('skip-reason', {
body: `Google Sheets connector unavailable: ${errorText}`,
contentType: 'text/plain',
});
test.skip(); // throws, no return needed
}
throw new Error(`Failed to create gsheets database: ${errorText}`);
}
const createDbBody = await createDbRes.json();
const dbId = createDbBody.result?.id ?? createDbBody.id;
if (!dbId) {
throw new Error('Database creation did not return an ID');
}
testAssets.trackDatabase(dbId);
// Navigate to create dataset page
const createDatasetPage = new CreateDatasetPage(page);
await createDatasetPage.goto();
await createDatasetPage.waitForPageLoad();
// Select the Google Sheets database
await createDatasetPage.selectDatabase(dbName);
// Try to select the sheet - if not found due to timeout, skip
try {
await createDatasetPage.selectTable(sheetName);
} catch (error) {
// Only skip on TimeoutError (sheet not loaded); re-throw everything else
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
await test.info().attach('skip-reason', {
body: `Table "${sheetName}" not found in dropdown after timeout.`,
contentType: 'text/plain',
});
test.skip(); // throws, no return needed
}
return { sheetName, dbName, createDatasetPage };
}
test('should create a dataset via wizard', async ({ page, testAssets }) => {
const { sheetName, createDatasetPage } = await setupGsheetsDataset(
page,
testAssets,
test.info(),
);
// Set up response intercept to capture new dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create and explore dataset" button
await createDatasetPage.clickCreateAndExploreDataset();
// Wait for dataset creation and capture ID for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const newDatasetId = createBody.result?.id ?? createBody.id;
if (newDatasetId) {
testAssets.trackDataset(newDatasetId);
}
// Verify we navigated to Chart Creation page with dataset pre-selected
await page.waitForURL(/.*\/chart\/add.*/);
const chartCreationPage = new ChartCreationPage(page);
await chartCreationPage.waitForPageLoad();
// Verify the dataset is pre-selected
await chartCreationPage.expectDatasetSelected(sheetName);
// Select a visualization type and create chart
await chartCreationPage.selectVizType('Table');
// Click "Create new chart" to go to Explore
await chartCreationPage.clickCreateNewChart();
// Verify we navigated to Explore page
await page.waitForURL(/.*\/explore\/.*/);
const explorePage = new ExplorePage(page);
await explorePage.waitForPageLoad();
// Verify the dataset name is shown in Explore
const loadedDatasetName = await explorePage.getDatasetName();
expect(loadedDatasetName).toContain(sheetName);
});
test('should create a dataset without exploring', async ({
page,
testAssets,
}) => {
const { sheetName, createDatasetPage } = await setupGsheetsDataset(
page,
testAssets,
test.info(),
);
// Set up response intercept to capture dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create dataset" (not explore)
await createDatasetPage.clickCreateDataset();
// Capture dataset ID from response for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const datasetId = createBody.result?.id ?? createBody.id;
if (datasetId) {
testAssets.trackDataset(datasetId);
}
// Verify redirect to dataset list (not chart creation)
// Note: "Create dataset" action does not show a toast
await page.waitForURL(/.*tablemodelview\/list.*/);
// Wait for table load, verify row visible
const datasetListPage = new DatasetListPage(page);
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(sheetName)).toBeVisible();
});

View File

@@ -17,76 +17,91 @@
* under the License.
*/
import { test, expect } from '@playwright/test';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import type { Response } from '@playwright/test';
import path from 'path';
import * as unzipper from 'unzipper';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { EditDatasetModal } from '../../../components/modals/EditDatasetModal';
import { Toast } from '../../../components/core/Toast';
import {
apiDeleteDataset,
apiGetDataset,
apiPostVirtualDataset,
getDatasetByName,
createTestVirtualDataset,
ENDPOINTS,
} from '../../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import {
waitForGet,
waitForPost,
waitForPut,
} from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
/**
* Test data constants
* PHYSICAL_DATASET: A physical dataset from examples (for navigation tests)
* Tests that need virtual datasets (duplicate/delete) create their own hermetic data
* Extend testWithAssets with datasetListPage navigation (beforeEach equivalent).
*/
const TEST_DATASETS = {
/** Physical dataset for basic navigation tests */
PHYSICAL_DATASET: 'birth_names',
} as const;
/**
* Dataset List E2E Tests
*
* Uses flat test() structure per project convention (matches login.spec.ts).
* Shared state and hooks are at file scope.
*/
// File-scope state (reset in beforeEach)
let datasetListPage: DatasetListPage;
let explorePage: ExplorePage;
let testResources: { datasetIds: number[] } = { datasetIds: [] };
test.beforeEach(async ({ page }) => {
datasetListPage = new DatasetListPage(page);
explorePage = new ExplorePage(page);
testResources = { datasetIds: [] }; // Reset for each test
// Navigate to dataset list page
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
const test = testWithAssets.extend<{ datasetListPage: DatasetListPage }>({
datasetListPage: async ({ page }, use) => {
const datasetListPage = new DatasetListPage(page);
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await use(datasetListPage);
},
});
test.afterEach(async ({ page }) => {
// Cleanup any resources created during the test
const promises = [];
for (const datasetId of testResources.datasetIds) {
promises.push(
apiDeleteDataset(page, datasetId, {
failOnStatusCode: false,
}).catch(error => {
// Log cleanup failures to avoid silent resource leaks
console.warn(
`[Cleanup] Failed to delete dataset ${datasetId}:`,
String(error),
);
}),
/**
* Helper to validate an export zip response.
* Verifies headers, parses zip contents, and validates expected structure.
*/
async function expectValidExportZip(
response: Response,
options: { minDatasetCount?: number; checkContentDisposition?: boolean } = {},
): Promise<void> {
const { minDatasetCount = 1, checkContentDisposition = false } = options;
// Verify headers
expect(response.headers()['content-type']).toContain('application/zip');
if (checkContentDisposition) {
expect(response.headers()['content-disposition']).toMatch(
/filename=.*dataset_export.*\.zip/,
);
}
await Promise.all(promises);
});
// Parse and validate zip contents
const body = await response.body();
expect(body.length).toBeGreaterThan(0);
const entries: string[] = [];
const directory = await unzipper.Open.buffer(body);
directory.files.forEach(file => entries.push(file.path));
// Validate structure
const datasetYamlFiles = entries.filter(
entry => entry.includes('datasets/') && entry.endsWith('.yaml'),
);
expect(datasetYamlFiles.length).toBeGreaterThanOrEqual(minDatasetCount);
expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true);
}
test('should navigate to Explore when dataset name is clicked', async ({
page,
datasetListPage,
}) => {
// Use existing physical dataset (loaded in CI via --load-examples)
const datasetName = TEST_DATASETS.PHYSICAL_DATASET;
const explorePage = new ExplorePage(page);
// Use existing example dataset (hermetic - loaded in CI via --load-examples)
const datasetName = 'members_channels_2';
const dataset = await getDatasetByName(page, datasetName);
expect(dataset).not.toBeNull();
@@ -108,16 +123,20 @@ test('should navigate to Explore when dataset name is clicked', async ({
await expect(explorePage.getVizSwitcher()).toContainText('Table');
});
test('should delete a dataset with confirmation', async ({ page }) => {
// Create a virtual dataset for this test (hermetic - no dependency on examples)
const datasetName = `test_delete_${Date.now()}`;
const datasetId = await createTestVirtualDataset(page, datasetName);
expect(datasetId).not.toBeNull();
test('should delete a dataset with confirmation', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create throwaway dataset for deletion
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_delete' },
);
// Track for cleanup in case test fails partway through
testResources = { datasetIds: [datasetId!] };
// Refresh page to see new dataset
// Refresh to see the new dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
@@ -148,31 +167,44 @@ test('should delete a dataset with confirmation', async ({ page }) => {
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Verify via API that dataset no longer exists (404)
await expect
.poll(
async () => {
const response = await apiGetDataset(page, datasetId, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${datasetId} should return 404` },
)
.toBe(404);
});
test('should duplicate a dataset with new name', async ({ page }) => {
// Create a virtual dataset for this test (hermetic - no dependency on examples)
const originalName = `test_original_${Date.now()}`;
const originalId = await createTestVirtualDataset(page, originalName);
expect(originalId).not.toBeNull();
test('should duplicate a dataset with new name', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create a virtual dataset first (duplicate UI only works for virtual datasets)
const { id: originalId, name: originalName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_duplicate_source' },
);
const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
// Track original for cleanup
testResources = { datasetIds: [originalId!] };
const duplicateName = `duplicate_${originalName}`;
// Refresh page to see new dataset
// Navigate to list and verify original dataset is visible
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify original dataset is visible in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
// Set up response intercept to capture duplicate dataset ID
const duplicateResponsePromise = page.waitForResponse(
response =>
response.url().includes(`${ENDPOINTS.DATASET}duplicate`) &&
response.status() === 201,
const duplicateResponsePromise = waitForPost(
page,
ENDPOINTS.DATASET_DUPLICATE,
);
// Click duplicate action button
@@ -188,13 +220,17 @@ test('should duplicate a dataset with new name', async ({ page }) => {
// Click the Duplicate button
await duplicateModal.clickDuplicate();
// Get the duplicate dataset ID from response
const duplicateResponse = await duplicateResponsePromise;
// Get the duplicate dataset ID from response (handle both response shapes)
const duplicateResponse = expectStatusOneOf(
await duplicateResponsePromise,
[200, 201],
);
const duplicateData = await duplicateResponse.json();
const duplicateId = duplicateData.id;
const duplicateId = duplicateData.result?.id ?? duplicateData.id;
expect(duplicateId, 'Duplicate API should return dataset id').toBeTruthy();
// Track both original and duplicate for cleanup
testResources = { datasetIds: [originalId!, duplicateId] };
// Track duplicate for cleanup (original is already tracked by createTestDataset)
testAssets.trackDataset(duplicateId);
// Modal should close
await duplicateModal.waitForHidden();
@@ -210,17 +246,437 @@ test('should duplicate a dataset with new name', async ({ page }) => {
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// API Verification: Compare original and duplicate datasets
const originalResponseData = await apiGetDataset(page, originalId!);
const originalDataFull = await originalResponseData.json();
const duplicateResponseData = await apiGetDataset(page, duplicateId);
const duplicateDataFull = await duplicateResponseData.json();
// API Verification: Fetch both datasets via detail API for consistent comparison
// (list API may return undefined for fields that detail API returns as null)
const [originalDetailRes, duplicateDetailRes] = await Promise.all([
apiGetDataset(page, originalId),
apiGetDataset(page, duplicateId),
]);
const originalDetail = (await originalDetailRes.json()).result;
const duplicateDetail = (await duplicateDetailRes.json()).result;
// Verify key properties were copied correctly
expect(duplicateDataFull.result.sql).toBe(originalDataFull.result.sql);
expect(duplicateDataFull.result.database.id).toBe(
originalDataFull.result.database.id,
);
expect(duplicateDetail.sql).toBe(originalDetail.sql);
expect(duplicateDetail.database.id).toBe(originalDetail.database.id);
expect(duplicateDetail.schema).toBe(originalDetail.schema);
// Name should be different (the duplicate name)
expect(duplicateDataFull.result.table_name).toBe(duplicateName);
expect(duplicateDetail.table_name).toBe(duplicateName);
});
test('should export a dataset as a zip file', async ({
page,
datasetListPage,
}) => {
// Use existing example dataset
const datasetName = 'members_channels_2';
const dataset = await getDatasetByName(page, datasetName);
expect(dataset).not.toBeNull();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Set up API response intercept for export endpoint
// Note: We intercept the API response instead of relying on download events because
// Superset uses blob downloads (createObjectURL) which don't trigger Playwright's
// download event consistently, especially in app-prefix configurations.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
// Click export action button
await datasetListPage.clickExportAction(datasetName);
// Wait for export API response and validate zip contents
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, { checkContentDisposition: true });
});
test('should export multiple datasets via bulk select action', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create 2 throwaway datasets for bulk export
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_export_1',
}),
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_export_2',
}),
]);
// Refresh to see new datasets
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
// Select both datasets
await datasetListPage.selectDatasetCheckbox(dataset1.name);
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
// Click bulk export action
await datasetListPage.clickBulkAction('Export');
// Wait for export API response and validate zip contains multiple datasets
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, { minDatasetCount: 2 });
});
test('should edit dataset name via modal', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create throwaway dataset for editing
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_edit' },
);
// Refresh to see new dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click edit action to open modal
await datasetListPage.clickEditAction(datasetName);
// Wait for edit modal to be ready
const editModal = new EditDatasetModal(page);
await editModal.waitForReady();
// Enable edit mode by clicking the lock icon
await editModal.enableEditMode();
// Edit the dataset name
const newName = `test_renamed_${Date.now()}`;
await editModal.fillName(newName);
// Set up response intercept for save
const saveResponsePromise = waitForPut(
page,
`${ENDPOINTS.DATASET}${datasetId}`,
);
// Click Save button
await editModal.clickSave();
// Handle the "Confirm save" dialog that may appear for datasets with sync columns enabled
const confirmDialog = new ConfirmDialog(page);
await confirmDialog.clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
// Wait for save to complete and verify success
expectStatusOneOf(await saveResponsePromise, [200, 201]);
// Modal should close
await editModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
// Verify via API that name was saved
const updatedDatasetRes = await apiGetDataset(page, datasetId);
const updatedDataset = (await updatedDatasetRes.json()).result;
expect(updatedDataset.table_name).toBe(newName);
});
test('should bulk delete multiple datasets', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create 2 throwaway datasets for bulk delete
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_delete_1',
}),
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_delete_2',
}),
]);
// Refresh to see new datasets
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
// Select both datasets
await datasetListPage.selectDatasetCheckbox(dataset1.name);
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Click bulk delete action
await datasetListPage.clickBulkAction('Delete');
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both datasets are removed from list
await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
// Verify via API that datasets no longer exist (404)
// Use polling with explicit timeout since deletes may be async
await expect
.poll(
async () => {
const response = await apiGetDataset(page, dataset1.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${dataset1.id} should return 404` },
)
.toBe(404);
await expect
.poll(
async () => {
const response = await apiGetDataset(page, dataset2.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${dataset2.id} should return 404` },
)
.toBe(404);
});
// Import test uses a fixed dataset name from the zip fixture.
// Uses test.describe only because Playwright's serial mode API requires it -
// this prevents race conditions when parallel workers import the same fixture.
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('import dataset', () => {
test.describe.configure({ mode: 'serial' });
test('should import a dataset from a zip file', async ({
page,
datasetListPage,
testAssets,
}) => {
// Dataset name from fixture (test_netflix_1768502050965)
// Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable
const importedDatasetName = 'test_netflix_1768502050965';
const fixturePath = path.resolve(
__dirname,
'../../../fixtures/dataset_export.zip',
);
// Cleanup: Delete any existing dataset with the same name from previous runs
const existingDataset = await getDatasetByName(page, importedDatasetName);
if (existingDataset) {
await apiDeleteDataset(page, existingDataset.id, {
failOnStatusCode: false,
});
}
// Click the import button
await datasetListPage.clickImportButton();
// Wait for import modal to be ready
const importModal = new ImportDatasetModal(page);
await importModal.waitForReady();
// Upload the fixture zip file
await importModal.uploadFile(fixturePath);
// Set up response intercept to catch the import POST
// Use pathMatch to avoid false matches if URL lacks trailing slash
let importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
pathMatch: true,
});
// Click Import button
await importModal.clickImport();
// Wait for first import response
let importResponse = await importResponsePromise;
// Handle overwrite confirmation if dataset already exists
// First response may be 409/422 indicating overwrite is required - this is expected
const overwriteInput = importModal.getOverwriteInput();
await overwriteInput
.waitFor({ state: 'visible', timeout: 3000 })
.catch(error => {
// Only ignore TimeoutError (input not visible); re-throw other errors
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
});
if (await overwriteInput.isVisible()) {
// Set up new intercept for the actual import after overwrite confirmation
importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
pathMatch: true,
});
await importModal.fillOverwriteConfirmation();
await importModal.clickImport();
// Wait for the second (final) import response
importResponse = await importResponsePromise;
}
// Check final import response for gsheets connector errors
if (!importResponse.ok()) {
const errorBody = await importResponse.json().catch(() => ({}));
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine') ||
errorText.includes('Could not load database driver')
) {
await test.info().attach('skip-reason', {
body: `Import failed due to missing gsheets connector: ${errorText}`,
contentType: 'text/plain',
});
test.skip();
return;
}
// Re-throw other errors
throw new Error(`Import failed: ${errorText}`);
}
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
// Refresh the page to see the imported dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset appears in list
await expect(
datasetListPage.getDatasetRow(importedDatasetName),
).toBeVisible();
// Get dataset ID for cleanup
const importedDataset = await getDatasetByName(page, importedDatasetName);
expect(importedDataset).not.toBeNull();
testAssets.trackDataset(importedDataset!.id);
});
});
test('should edit column date format via modal', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create virtual dataset with a date column for testing
// Using SQL to create a dataset with 'ds' column avoids duplication issues
const datasetName = `test_date_format_${Date.now()}_${test.info().parallelIndex}`;
const baseDataset = await getDatasetByName(page, 'members_channels_2');
expect(baseDataset, 'members_channels_2 dataset must exist').not.toBeNull();
const createResponse = await apiPostVirtualDataset(page, {
database: baseDataset!.database.id,
schema: baseDataset!.schema ?? null,
table_name: datasetName,
sql: "SELECT CAST('2024-01-01' AS DATE) as ds, 'test' as name",
});
expectStatusOneOf(createResponse, [200, 201]);
const createBody = await createResponse.json();
const datasetId = createBody.result?.id ?? createBody.id;
expect(datasetId, 'Virtual dataset creation should return id').toBeTruthy();
testAssets.trackDataset(datasetId);
// Navigate to dataset list, click edit action
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await datasetListPage.clickEditAction(datasetName);
// Enable edit mode, navigate to Columns tab
const editModal = new EditDatasetModal(page);
await editModal.waitForReady();
await editModal.enableEditMode();
await editModal.clickColumnsTab();
// Expand 'ds' column row and fill date format (scoped to row)
const dateFormat = '%Y-%m-%d';
await editModal.fillColumnDateFormat('ds', dateFormat);
// Save and handle confirmation dialog conditionally
await editModal.clickSave();
await new ConfirmDialog(page).clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
await editModal.waitForHidden();
// Verify via API
const updatedRes = await apiGetDataset(page, datasetId);
const columns = (await updatedRes.json()).result.columns;
const dsColumn = columns.find(
(c: { column_name: string }) => c.column_name === 'ds',
);
expect(dsColumn, 'ds column should exist in dataset').toBeDefined();
expect(dsColumn.python_date_format).toBe(dateFormat);
});
test('should edit dataset description via modal', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create throwaway dataset for editing description
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_description' },
);
// Navigate to dataset list, click edit action
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await datasetListPage.clickEditAction(datasetName);
// Enable edit mode, navigate to Settings tab
const editModal = new EditDatasetModal(page);
await editModal.waitForReady();
await editModal.enableEditMode();
await editModal.clickSettingsTab();
// Fill description field
const description = `Test description ${Date.now()}`;
await editModal.fillDescription(description);
// Save and handle confirmation dialog conditionally
await editModal.clickSave();
await new ConfirmDialog(page).clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
await editModal.waitForHidden();
// Verify via API
const updatedRes = await apiGetDataset(page, datasetId);
const result = (await updatedRes.json()).result;
expect(result.description).toBe(description);
});

View File

@@ -0,0 +1,67 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { createTestVirtualDataset } from '../../../helpers/api/dataset';
interface TestDatasetResult {
id: number;
name: string;
}
interface CreateTestDatasetOptions {
/** Prefix for generated name (default: 'test') */
prefix?: string;
}
/**
* Creates a test virtual dataset.
* Uses createTestVirtualDataset() to create a simple virtual dataset for testing.
*
* Note: The dataset duplicate API only works with virtual datasets. This helper
* creates virtual datasets directly to avoid that limitation.
*
* @example
* // Basic usage
* const { id, name } = await createTestDataset(page, testAssets, test.info());
*
* @example
* // Custom prefix
* const { id, name } = await createTestDataset(page, testAssets, test.info(), {
* prefix: 'test_delete',
* });
*/
export async function createTestDataset(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options?: CreateTestDatasetOptions,
): Promise<TestDatasetResult> {
const prefix = options?.prefix ?? 'test';
const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`;
const id = await createTestVirtualDataset(page, name);
if (!id) {
throw new Error(`Failed to create test dataset: ${name}`);
}
testAssets.trackDataset(id);
return { id, name };
}

View File

@@ -48,4 +48,14 @@ export const TIMEOUT = {
* API response timeout for operations like export/download
*/
API_RESPONSE: 15000, // 15s for API responses and downloads
/**
* Confirmation dialog wait (e.g., "Confirm save", "Are you sure?")
*/
CONFIRM_DIALOG: 2000, // 2s for confirmation dialogs that may or may not appear
/**
* File import/upload operations (upload + server processing)
*/
FILE_IMPORT: 30000, // 30s for file import operations
} as const;

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