Compare commits

...

90 Commits

Author SHA1 Message Date
Evan
95b46bcf02 fix(migration): point down_revision to actual master head (33d7e0e21daa)
Previous commits used a1b2c3d4e5f6 then ce6bd21901ab, both of which are
mid-chain on master. The true head is 33d7e0e21daa (add_semantic_layers).
Branching off mid-chain caused 'Multiple head revisions' in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:42:00 -07:00
Evan
7f804262ed fix(migration): chain to correct head and transfer role bindings on merge
- Point down_revision to ce6bd21901ab (the actual current chain head,
  not a1b2c3d4e5f6 which already had two successors)
- In the "both rows exist" merge path, transfer ab_permission_view_role
  bindings from the lowercase PVM to the surviving uppercase PVM before
  deleting the duplicate, so no role silently loses a Security permission

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:29:57 -07:00
Evan
2063484ef4 fix(security): correct class_permission_name case for Security menu views
Five views that back the Security menu navbar items declared
`class_permission_name = "security"` (lowercase). Flask-AppBuilder uses
this name to look up or insert the corresponding `ab_view_menu` row. On
MySQL's default case-insensitive collation, the lookup finds whatever
row exists first, meaning the DB can end up storing `"security"` instead
of `"Security"`. Because Superset's Python comparisons in
`sync_role_definitions` and `SupersetSecurityManager` are case-sensitive,
`"security" != "Security"` → the Security menu disappears from the UI on
MySQL/MariaDB deployments.

Fixes:
- Change `class_permission_name` to `"Security"` in users_list.py,
  roles.py, groups.py, logs.py, and user_registrations.py.
- Add migration `b4a3f2e1d0c9` that normalises any existing lowercase
  `"security"` row: renames it to `"Security"` if it is the only row,
  or merges its `ab_permission_view` entries into the correctly-cased
  row and deletes the duplicate if both exist.

Closes #40330

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:17:16 -07:00
dependabot[bot]
9441240e5c chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40436)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:56 -07:00
dependabot[bot]
08164e33bb chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#40437)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:41 -07:00
dependabot[bot]
894058fe3d chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40438)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:24 -07:00
dependabot[bot]
6bd1b46216 chore(deps): bump github/codeql-action from 4.35.5 to 4.36.0 (#40458)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:06 -07:00
Deva
ef4514f5ab fix(treemap): remove gaps between chart nodes (#40181) 2026-05-27 22:35:06 +03:00
Amin Ghadersohi
e041f25385 fix(mcp): return error when target_tab not found in add_chart_to_existing_dashboard (#40124) 2026-05-27 14:29:43 -04:00
Evan Rusackas
d744f5715c fix(dashboard-export): include and re-attach roles in import/export (#21000) (#40136)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: SBIN2010 <Sbin2010@mail.ru>
2026-05-27 10:49:07 -07:00
Amin Ghadersohi
fb60662353 chore(mcp): revert browser-friendly hello page for GET /mcp from browsers (#40467) 2026-05-27 11:43:01 -04:00
Onur Taşhan
207a7bf7f9 fix: preserve dashboard certification when saving layout changes (#40193)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:03:43 -04:00
Amin Ghadersohi
09a94fa26b feat(mcp): return browser-friendly hello page for GET /mcp from browsers (#40309)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:52:37 -07:00
Mike Bridge
7e088792b9 test(model): roll back uncommitted ds_col mutations in timestamp-expression tests (#40451)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-05-26 21:17:08 -03:00
Maxime Beauchemin
b6f545e61e feat(mcp): resolve call_tool proxy name and capture error_type in logging (#38915)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-05-26 14:37:37 -04:00
Amin Ghadersohi
952a6f3a23 fix(mcp): prevent encoding error on tools/list when middleware raises (#40446) 2026-05-26 12:50:31 -04:00
dependabot[bot]
8b551d3f74 chore(deps-dev): bump duckdb from 1.4.2 to 1.5.2 (#40381)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 22:56:50 -07:00
dependabot[bot]
709ef9b615 chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#40321)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:08:10 -07:00
dependabot[bot]
e9d46d843f chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40415)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:41 -07:00
dependabot[bot]
9cc2deb903 chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40416)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:27 -07:00
dependabot[bot]
03d25277ba chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#40417)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:14 -07:00
dependabot[bot]
bbe2f207d2 chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40418)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:00 -07:00
dependabot[bot]
c381677dfd chore(deps): bump click from 8.2.1 to 8.4.0 (#40312)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:55:38 -07:00
dependabot[bot]
09572cd5ef chore(deps): bump tabulate from 0.9.0 to 0.10.0 (#40315)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Evan <evan@preset.io>
2026-05-25 18:55:24 -07:00
Alexandru Soare
33585b0480 feat(mcp): make form_data_key optional in update_chart_preview (#39680) 2026-05-25 15:09:36 +03:00
Alexandru Soare
b64561f3a3 chore(mcp): Simplify chart preview response (#40020) 2026-05-25 13:16:27 +03:00
xavier-GitHub76
fe484f6bb2 fix(UserListModal): Success notification mentions user and not group (#40284) 2026-05-25 00:03:14 +03:00
dependabot[bot]
8caa74354f chore(deps): bump markdown from 3.8.1 to 3.10.2 (#40389)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:18:33 -07:00
Evan Rusackas
9c90a6854c ci(translations): hard-block translation regressions in CI (#39443)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-23 16:30:14 -07:00
Evan Rusackas
2fef4e41f2 feat(i18n): add Finnish (fi) translations (AI-generated, needs review) (#40390)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:29:44 -07:00
Jean Massucatto
965ec47296 fix(explore): hide value input for unary filter operators (#39924) 2026-05-23 11:43:49 -07:00
Evan Rusackas
b21450681d feat(i18n): add Thai (th) translations (AI-generated, needs review) (#40391)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:35:19 -07:00
Torsten Stöter
5003ee1499 docs: remove out-of-place phrase (#40226) 2026-05-23 13:42:52 +07:00
Abdul Rehman
816794b198 fix(frontend): handle null/undefined path in ensureAppRoot (#39940)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-23 13:22:27 +07:00
dependabot[bot]
841871f1e7 chore(deps): bump qs from 6.14.2 to 6.15.2 in /superset-websocket/utils/client-ws-app (#40382)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 12:00:20 +07:00
dependabot[bot]
55203bbc74 chore(deps): bump qs from 6.14.2 to 6.15.2 in /docs (#40383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 11:55:04 +07:00
Evan Rusackas
2fa3bbd91c chore(ci): limit /app/prefix matrix variant to master merges (#40385)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:11:21 -07:00
Evan Rusackas
168b49bf34 chore(cypress): remove dead _skip spec files and skipped inline tests (#40384)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:11:13 -07:00
Evan Rusackas
838ac8f553 fix(ci): stop cancelling Hold Label Check runs (#40380)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:10:23 -07:00
Evan Rusackas
42668cf634 ci(docker): pin QEMU binfmt image to stabilize arm64 builds (#40235)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 21:09:54 -07:00
Evan Rusackas
8d985d223b ci(e2e): run backend under gunicorn instead of flask dev server (#40234)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 21:09:14 -07:00
Evan Rusackas
e57387098b fix(bigquery): limit result set size to prevent browser memory crashes (#38588)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: ethan-l-geotab <ethanliong@geotab.com>
2026-05-22 21:07:45 -07:00
Evan Rusackas
af6ac4d09c feat(i18n): AI-assisted translation backfill tooling + Spanish translations (#39448)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-22 21:07:27 -07:00
yousoph
f8e13770fc fix(dashboard): add top padding to "Create new chart" button in builder pane (#40033)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:37:04 -07:00
Miha Rejec
5cdd542ae5 fix(i18n): translate DateFilter tooltip for time range values (#40286)
Co-authored-by: Miha Rejec <mihar@comland.si>
2026-05-23 00:22:31 +03:00
dependabot[bot]
e40648dfcb chore(deps-dev): bump typescript from 5.9.3 to 6.0.3 in /superset-websocket (#39425)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:39:07 -07:00
dependabot[bot]
c728b4a11f chore(deps): bump sqlglot from 28.10.0 to 30.8.0 (#40186)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-22 11:35:30 -07:00
dependabot[bot]
0febe32dc9 chore(deps): bump geostyler from 18.5.1 to 18.6.0 in /superset-frontend (#40323)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-22 11:34:30 -07:00
dependabot[bot]
2a0ebd7055 chore(deps-dev): bump ts-jest from 29.4.10 to 29.4.11 in /superset-websocket (#40363)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:34:16 -07:00
dependabot[bot]
6e23e4541d chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40365)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:58 -07:00
dependabot[bot]
5af8fe77fa chore(deps): bump zod from 4.4.1 to 4.4.3 in /superset-frontend (#40367)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:44 -07:00
dependabot[bot]
3599c78a03 chore(deps): bump react-arborist from 3.6.1 to 3.7.0 in /superset-frontend (#40371)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:28 -07:00
dependabot[bot]
d97b5d6509 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40372)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:12 -07:00
dependabot[bot]
869ab37f59 chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40366)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:28:14 -07:00
Joe Li
3b4892c48c fix(select): replace cached options with search results in AsyncSelect (#40039)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:25:03 -07:00
Đỗ Trọng Hải
91d96419fe feat(sec): delays version-bumping PR to avoid prematurely usage of compromised packages (#39783)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Copilot <copilot@github.com>
2026-05-22 11:01:10 -07:00
Ali Gouta
42149f6a78 fix(chart): fix label and description translation on UI and enhance french translations (#40229)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-22 10:47:55 -07:00
Evan Rusackas
c945ef6763 chore(oxlint): enable import/newline-after-import + react/no-unstable-nested-components (#40319)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:11:38 -07:00
Beto Dealmeida
21059b54f0 feat(semantic layers): form for SL with a single SV (#40280) 2026-05-22 12:04:25 -04:00
Mehmet Salih Yavuz
8ab4695ba3 fix(mcp): use name URL param so AI-generated SQL Lab titles render (#40288) 2026-05-22 17:35:08 +03:00
dependabot[bot]
b0d26196fc chore(deps-dev): bump @swc/plugin-emotion from 14.9.0 to 14.10.0 in /superset-frontend (#40368)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:31:01 +07:00
dependabot[bot]
df8222ffcd chore(deps-dev): bump ts-jest from 29.4.10 to 29.4.11 in /superset-frontend (#40369)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:28:40 +07:00
Đỗ Trọng Hải
64f0e88de7 chore(backend/build): upgrade Gunicorn from v22 to v25 (#38788)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-22 21:24:58 +07:00
Đỗ Trọng Hải
f4af6a2caf fix(docker): add missing service-worker.js into built container image (#39596)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-22 21:23:21 +07:00
dependabot[bot]
31087177ab chore(deps-dev): bump webpack from 5.107.0 to 5.107.1 in /docs (#40364)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:21:22 +07:00
Shaitan
8e98ca6569 docs: expand out-of-scope vulnerability definitions (#40332) 2026-05-22 21:20:57 +07:00
dependabot[bot]
f7f6c29adf chore(deps-dev): bump webpack from 5.106.2 to 5.107.1 in /superset-frontend (#40370)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:15:44 +07:00
Miha Rejec
a94edfe418 fix(i18n): add Slovenian translation for 'Range type' in DateFilter (#40287)
Co-authored-by: Miha Rejec <mihar@comland.si>
2026-05-22 09:58:27 -04:00
David Kopelent
5549100601 feat(i18n): add missing Slovak translations (#40219) 2026-05-22 09:56:43 -04:00
Alexandru Soare
558ff4452b fix(preview): fix chart preview bugs (#40063) 2026-05-22 13:42:59 +03:00
Amin Ghadersohi
5966bb1c1e feat(mcp): add series_limit to generate_chart XY config (#40307)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:36:30 -04:00
chaselynisabella
ac035083d7 feat(path): support metric-based color scales & line width by metric (#39165) 2026-05-21 19:31:15 -04:00
Amin Ghadersohi
e25d708197 fix(mcp): hide write tools from users without write permissions (#40098)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 19:11:46 -04:00
dependabot[bot]
48cb3f5885 chore(deps-dev): bump baseline-browser-mapping from 2.10.29 to 2.10.31 in /superset-frontend (#40320)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:45:02 -07:00
dependabot[bot]
dcef6f8a41 chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40322)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:44:46 -07:00
dependabot[bot]
f09fd63495 chore(deps): bump @googleapis/sheets from 13.0.1 to 13.0.2 in /superset-frontend (#40324)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:44:05 -07:00
dependabot[bot]
bc26006a43 chore(deps-dev): update sqlalchemy-drill requirement from <2,>=1.1.4 to >=1.1.10,<2 (#40310)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:42:40 -07:00
dependabot[bot]
8b483f320e chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40325)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:41:40 -07:00
Evan Rusackas
89c2a47433 fix(TableView): reset pagination when data reduces below current page (#34562)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 13:39:05 -07:00
JUST.in DO IT
b8b91574e0 fix(view query): Update style for code viewer container (#39635)
Co-authored-by: Copilot <copilot@github.com>
2026-05-21 13:37:56 -07:00
Jay Masiwal
5526464def fix(frontend): update safeStringify to surface [Circular] and DRY plugin code (#39156) 2026-05-21 13:37:05 -07:00
Evan Rusackas
73f66e4c14 fix(datasets): isolate filter state to fix concurrent /dataset race (#39685)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-21 11:12:32 -07:00
Elizabeth Thompson
f187a8e1c4 fix(reports): guard null dashboard height in Playwright screenshots (#40179) 2026-05-21 09:19:29 -07:00
Mehmet Salih Yavuz
4c3f65ef0b feat(mcp): make config optional in generate_explore_link (#39559) 2026-05-21 18:01:59 +03:00
Mehmet Salih Yavuz
53d8e5bdfa feat(mcp): include applied dashboard filters in get_chart_info (#39620)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:48:21 +03:00
Mehmet Salih Yavuz
2f95d288dd fix(mcp): eager-load dataset.metrics to prevent Excel export DetachedInstanceError (#39483)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:34:38 +03:00
Beto Dealmeida
2f5fcc21f9 fix(semantic layers): coerce filter types (#40222) 2026-05-21 09:25:27 -04:00
Mehmet Salih Yavuz
d1d07112aa feat(mcp): add find_users tool and owner filter columns for listings (#39679)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:59:09 +03:00
Alexandru Soare
e3711bec39 fix(recommandation): Fix chart recommandation (#39886) 2026-05-21 15:16:16 +03:00
Mehmet Salih Yavuz
ce9cab098f feat(mcp): chart formatting options across all supported chart types (#39887) 2026-05-21 15:00:32 +03:00
204 changed files with 79924 additions and 15770 deletions

16
.github/SECURITY.md vendored
View File

@@ -33,13 +33,21 @@ We kindly ask you to include the following information in your report to assist
- Expected vs. Actual Behavior: A clear description of the intended system behavior versus the observed vulnerability.
- Detailed Reproduction Steps: Clear, manual steps to reproduce the vulnerability.
**Vulnerability Definition**
Apache Superset considers a security vulnerability to be a demonstrable issue that has meaningful impact on confidentiality, integrity, or availability beyond the intended security model. Low-impact boundary variations or technical edge cases in existing access controls may be classified as hardening improvements rather than vulnerabilities, even if exploitable.
**Out of Scope Vulnerabilities**
To prioritize engineering efforts on genuine architectural risks, the following scenarios are explicitly out of scope and will not be issued a CVE:
- Attacks requiring Admin privileges: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
- Brute Force and Rate Limiting: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
- Theoretical attack vectors: Issues without a demonstrable, reproducible exploit path.
- Non-Exploitable Findings: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
- **Attacks requiring Admin privileges**: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
- **Brute Force and Rate Limiting**: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
- **Theoretical attack vectors**: Issues without a demonstrable, reproducible exploit path.
- **Non-Exploitable Findings**: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
- **User enumeration**: API responses, timing differences, or error messages that reveal whether user accounts, IDs, dashboards, or datasets exist.
- **Information disclosure (low impact)**: Software version disclosure, generic error messages, stack traces without sensitive data exposure, or system configuration details that don't enable further exploitation.
- **Resource exhaustion requiring authentication**: Denial of Service attacks that require valid user credentials and don't bypass rate limiting or resource controls.
- **Missing security headers**: Without demonstration of a concrete exploit scenario that leverages the missing header.
**Outcome of Reports**

View File

@@ -27,6 +27,15 @@ runs:
- name: Set up QEMU
if: ${{ inputs.build == 'true' }}
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
with:
# Pin the binfmt image to a specific QEMU release. The default
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
# QEMU's x86_64→aarch64 translator has been the proximate cause of
# intermittent `exit code: 132` (SIGILL) failures during the arm64
# leg of the multi-platform docker build — newer Node native modules
# emit instructions QEMU's user-mode emulation occasionally drops on
# the floor. Pinning a known-good release stabilises that path.
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
if: ${{ inputs.build == 'true' }}

View File

@@ -1,7 +1,6 @@
version: 2
enable-beta-ecosystems: true
updates:
- package-ecosystem: "github-actions"
directory: "/"
ignore:
@@ -10,6 +9,8 @@ updates:
- dependency-name: anthropics/claude-code-action
schedule:
interval: "daily"
cooldown:
default-days: 5
- package-ecosystem: "npm"
ignore:
@@ -57,6 +58,8 @@ updates:
- dependabot
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "pip"
@@ -72,6 +75,8 @@ updates:
labels:
- pip
- dependabot
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: ".github/actions"
@@ -79,6 +84,8 @@ updates:
interval: "daily"
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/docs/"
@@ -102,6 +109,8 @@ updates:
interval: "daily"
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-websocket/"
@@ -111,6 +120,8 @@ updates:
- npm
- dependabot
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-websocket/utils/client-ws-app/"
@@ -121,6 +132,8 @@ updates:
- dependabot
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 5
# Now for all of our plugins and packages!
@@ -133,6 +146,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
@@ -143,6 +158,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
@@ -153,6 +170,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
@@ -166,6 +185,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
@@ -176,6 +197,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
@@ -186,6 +209,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
@@ -196,6 +221,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
@@ -206,6 +233,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-table/"
@@ -219,6 +248,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
@@ -229,6 +260,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
@@ -239,6 +272,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
@@ -249,6 +284,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
@@ -259,6 +296,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
@@ -269,6 +308,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
@@ -279,6 +320,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
@@ -289,6 +332,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
@@ -299,6 +344,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
@@ -309,6 +356,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
@@ -323,6 +372,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/generator-superset/"
@@ -333,6 +384,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
@@ -343,6 +396,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-core/"
@@ -358,6 +413,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-switchboard/"
@@ -368,3 +425,5 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5

View File

@@ -175,10 +175,13 @@ cypress-run-all() {
local APP_ROOT=$2
cd "$GITHUB_WORKSPACE/superset-frontend/cypress-base"
# Start Flask and run it in background
# --no-debugger means disable the interactive debugger on the 500 page
# so errors can print to stderr.
local flasklog="${HOME}/flask.log"
# Start the Superset backend via gunicorn (not `flask run`). The Flask
# development server is single-threaded and has no crash-recovery, so
# heavy tests (dashboard import/export, SQL Lab) can knock it offline
# for the rest of the run — surfacing as `ECONNREFUSED` / `socket hang up`
# / `Missing CSRF token` cascades. Gunicorn gives us multiple workers,
# a request timeout, and worker-recycling under load.
local serverlog="${HOME}/superset-cypress.log"
local port=8081
CYPRESS_BASE_URL="http://localhost:${port}"
if [ -n "$APP_ROOT" ]; then
@@ -187,8 +190,58 @@ cypress-run-all() {
fi
export CYPRESS_BASE_URL
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
local flaskProcessId=$!
# Mirrors the args in docker/entrypoints/run-server.sh (1 worker × 20
# gthread threads) to keep parity with production. Multi-worker
# configurations expose timing-sensitive races in the SQL Lab → Explore
# navigation flow under E2E. We diverge from the entrypoint on:
# --timeout 120: heavy dashboard import/export specs exceed the 60s
# default
# --max-requests / --max-requests-jitter: recycle the worker under
# test load to avoid leaks accumulating across the run
# superset.app:create_app(): explicit factory so we don't depend on
# FLASK_APP being exported
nohup gunicorn \
--bind "127.0.0.1:$port" \
--workers 1 \
--worker-class gthread \
--threads 20 \
--timeout 120 \
--max-requests 500 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile - \
"superset.app:create_app()" \
>"$serverlog" 2>&1 </dev/null &
local serverPid=$!
# Ensure the backend is cleaned up and its log is emitted even when the
# test runner fails under `set -e`.
trap '
echo "::group::gunicorn log for Cypress run"
cat "'"$serverlog"'" || true
echo "::endgroup::"
kill '"$serverPid"' 2>/dev/null || true
' EXIT
# Wait for the backend to be ready before launching Cypress; otherwise
# the first spec can race the server bind and see connection errors.
local timeout=60
say "Waiting for gunicorn server to start on port $port..."
while [ $timeout -gt 0 ]; do
if curl -f "http://localhost:${port}${APP_ROOT}/health" >/dev/null 2>&1; then
say "gunicorn server is ready"
break
fi
sleep 1
timeout=$((timeout - 1))
done
if [ $timeout -eq 0 ]; then
echo "::error::gunicorn server failed to start within 60 seconds"
echo "::group::Server startup log"
cat "$serverlog"
echo "::endgroup::"
return 1
fi
USE_DASHBOARD_FLAG=''
if [ "$USE_DASHBOARD" = "true" ]; then
@@ -200,13 +253,6 @@ cypress-run-all() {
# memoryMonitorPid=$!
python ../../scripts/cypress_run.py --parallelism $PARALLELISM --parallelism-id $PARALLEL_ID --group $PARALLEL_ID --retries 5 $USE_DASHBOARD_FLAG
# kill $memoryMonitorPid
# After job is done, print out Flask log for debugging
echo "::group::Flask log for default run"
cat "$flasklog"
echo "::endgroup::"
# make sure the program exits
kill $flaskProcessId
}
playwright-install() {
@@ -224,9 +270,11 @@ playwright-run() {
local APP_ROOT=$1
local TEST_PATH=$2
# Start Flask from the project root (same as Cypress)
# Start the Superset backend via gunicorn from the project root.
# See cypress-run-all() above for the rationale — the Flask dev server
# cannot survive the dashboard import/export tests under load.
cd "$GITHUB_WORKSPACE"
local flasklog="${HOME}/flask-playwright.log"
local serverlog="${HOME}/superset-playwright.log"
local port=8081
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
if [ -n "$APP_ROOT" ]; then
@@ -235,18 +283,37 @@ playwright-run() {
fi
export PLAYWRIGHT_BASE_URL
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
local flaskProcessId=$!
# See cypress-run-all() above for the args rationale (1 worker × 20
# gthread threads matching docker/entrypoints/run-server.sh, plus a
# 120s timeout and request-recycling for heavy E2E load).
nohup gunicorn \
--bind "127.0.0.1:$port" \
--workers 1 \
--worker-class gthread \
--threads 20 \
--timeout 120 \
--max-requests 500 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile - \
"superset.app:create_app()" \
>"$serverlog" 2>&1 </dev/null &
local serverPid=$!
# Ensure cleanup on exit
trap "kill $flaskProcessId 2>/dev/null || true" EXIT
# Ensure cleanup on exit (and emit the server log on failure)
trap '
echo "::group::gunicorn log for Playwright run"
cat "'"$serverlog"'" || true
echo "::endgroup::"
kill '"$serverPid"' 2>/dev/null || true
' EXIT
# Wait for server to be ready with health check
local timeout=60
say "Waiting for Flask server to start on port $port..."
say "Waiting for gunicorn server to start on port $port..."
while [ $timeout -gt 0 ]; do
if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
say "Flask server is ready"
say "gunicorn server is ready"
break
fi
sleep 1
@@ -254,9 +321,9 @@ playwright-run() {
done
if [ $timeout -eq 0 ]; then
echo "::error::Flask server failed to start within 60 seconds"
echo "::group::Flask startup log"
cat "$flasklog"
echo "::error::gunicorn server failed to start within 60 seconds"
echo "::group::Server startup log"
cat "$serverlog"
echo "::endgroup::"
return 1
fi
@@ -271,7 +338,6 @@ playwright-run() {
if ! find "playwright/tests/${TEST_PATH}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
echo "No test files found in ${TEST_PATH} - skipping test run"
say "::endgroup::"
kill $flaskProcessId
return 0
fi
echo "Running tests: ${TEST_PATH}"
@@ -288,13 +354,6 @@ playwright-run() {
fi
say "::endgroup::"
# After job is done, print out Flask log for debugging
echo "::group::Flask log for Playwright run"
cat "$flasklog"
echo "::endgroup::"
# make sure the program exits
kill $flaskProcessId
return $status
}

View File

@@ -41,7 +41,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,6 +53,6 @@ jobs:
- name: Perform CodeQL Analysis
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -7,10 +7,13 @@ on:
permissions:
pull-requests: read
# cancel previous workflow jobs for PRs
# Let each label event run to completion. Cancelling in-progress runs leaves
# CANCELLED entries in the PR's check-suite rollup, which poisons GitHub's
# `status:success` search filter even though all real CI passed. The job is
# a tiny no-op github-script call, so the wasted compute is negligible.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
cancel-in-progress: false
jobs:
check-hold-label:

View File

@@ -42,7 +42,7 @@ jobs:
matrix:
parallel_id: [0, 1, 2, 3, 4, 5]
browser: ["chrome"]
app_root: ["", "/app/prefix"]
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -161,7 +161,7 @@ jobs:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config

View File

@@ -0,0 +1,87 @@
name: Translation Regression Comment
on:
workflow_run:
workflows: ["Translations"]
types: [completed]
# This workflow posts a PR comment when the Translations workflow detects a
# regression. It uses the workflow_run trigger so that it always runs in the
# base-branch context and can safely be granted write permissions, even for
# PRs from forks.
#
# IMPORTANT: This workflow must NEVER check out code from the PR branch.
# All data comes from the artifact uploaded by the Translations workflow.
permissions:
pull-requests: write
actions: read
jobs:
post-comment:
runs-on: ubuntu-24.04
# Only act when the Translations workflow failed (which means a regression
# was detected — the workflow exits 1 on regression).
if: github.event.workflow_run.conclusion == 'failure'
steps:
- name: Download regression artifact
id: download
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: translation-regression
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: /tmp/translation-regression
- name: Post or update PR comment
if: steps.download.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const prNumberFile = '/tmp/translation-regression/pr-number.txt';
const reportFile = '/tmp/translation-regression/regression-report.md';
if (!fs.existsSync(prNumberFile) || !fs.existsSync(reportFile)) {
console.log('Artifact files not found, skipping comment.');
return;
}
const prNumber = parseInt(fs.readFileSync(prNumberFile, 'utf8').trim(), 10);
if (!prNumber) {
console.log('Could not parse PR number, skipping comment.');
return;
}
const report = fs.readFileSync(reportFile, 'utf8');
const marker = '<!-- translation-regression-bot -->';
const body = `${marker}\n${report}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
console.log(`Updated existing comment ${existing.id} on PR #${prNumber}`);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
console.log(`Created new comment on PR #${prNumber}`);
}

View File

@@ -20,6 +20,9 @@ concurrency:
jobs:
frontend-check-translations:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -51,12 +54,16 @@ jobs:
babel-extract:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
@@ -64,12 +71,85 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
if: steps.check.outputs.python
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
uses: ./.github/actions/setup-backend/
- name: Install msgcat
run: sudo apt update && sudo apt install gettext
- name: Install gettext tools
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: sudo apt-get update && sudo apt-get install -y gettext
- name: Test babel extraction
if: steps.check.outputs.python
# Fetch the base ref so we can compare PR-introduced regressions
# against a fair baseline (also runs babel_update against the base
# source) — this isolates the PR's contribution from any pre-existing
# drift on the base branch.
- name: Fetch base ref and create comparison worktree
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: |
# For PRs use the base branch; for direct pushes compare against the previous commit.
BASE_REF="${{ github.event.pull_request.base.ref }}"
if [ -n "$BASE_REF" ]; then
git fetch --depth=1 origin "$BASE_REF"
else
git fetch --depth=2 origin "${{ github.ref }}"
fi
git worktree add /tmp/base-worktree FETCH_HEAD
# Run babel_update against BASE source + BASE translations. Any drift
# already present on the base branch (source strings that have changed
# without .po updates) shows up here as fuzzies — and will also show
# up in the PR run, so it cancels out in the comparison.
- name: Baseline — run babel_update against BASE source
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
working-directory: /tmp/base-worktree
run: ./scripts/translations/babel_update.sh
- name: Record baseline translation counts
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: |
python scripts/translations/check_translation_regression.py \
--count \
--translations-dir /tmp/base-worktree/superset/translations \
> /tmp/before.json
# Reset the PR worktree's translations to the pristine BASE state so
# both babel_update runs start from the same .po files. The only
# difference between the runs is the source code.
- name: Reset PR worktree translations to pristine BASE
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: git checkout FETCH_HEAD -- superset/translations/
- name: Run babel_update against PR source
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: ./scripts/translations/babel_update.sh
- name: Check for translation regression
id: regression
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
continue-on-error: true
run: |
python scripts/translations/check_translation_regression.py \
--compare /tmp/before.json \
--report /tmp/regression-report.md
# Save the PR number so the comment workflow can post the report without
# needing write permissions on this pull_request-triggered job.
- name: Save PR number for comment workflow
if: >-
github.event_name == 'pull_request' &&
steps.regression.outcome == 'failure'
run: echo "${{ github.event.pull_request.number }}" > /tmp/pr-number.txt
- name: Upload regression artifact
if: >-
github.event_name == 'pull_request' &&
steps.regression.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: translation-regression
path: |
/tmp/regression-report.md
/tmp/pr-number.txt
- name: Fail if regression detected
if: steps.regression.outcome == 'failure'
run: exit 1

2
.gitignore vendored
View File

@@ -115,6 +115,8 @@ release.json
superset/translations/**/messages.json
# these mo binary files are generated by `pybabel compile`
superset/translations/**/messages.mo
# cross-language index generated by scripts/translations/build_translation_index.py
superset/translations/translation_index.json
docker/requirements-local.txt

View File

@@ -50,7 +50,7 @@ repos:
hooks:
- id: check-docstring-first
- id: check-added-large-files
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$|^superset/translations/.*\.po$
- id: check-yaml
exclude: ^helm/superset/templates/
- id: debug-statements

View File

@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
[MESSAGES CONTROL]
disable=all
enable=json-import,disallowed-sql-import,consider-using-transaction
enable=disallowed-sql-import,consider-using-transaction
[REPORTS]

View File

@@ -202,6 +202,8 @@ RUN mkdir -p /app/data && chown -R superset:superset /app/data
# Copy compiled things from previous stages
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset

View File

@@ -88,7 +88,6 @@ using our `docker compose` constructs to support production-type use-cases. For
environments, we recommend using [minikube](https://minikube.sigs.k8s.io/docs/start/) along
our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
documentation.
configured to be secure.
:::
### Supported environment variables

View File

@@ -335,6 +335,92 @@ npm run build-translation
pybabel compile -d superset/translations
```
### Backfilling missing translations with AI
For languages with many untranslated strings, the repo includes a script that
uses Claude AI to generate draft translations for any missing entries. All
AI-generated strings are marked `#, fuzzy` and tagged with an attribution
comment so that human reviewers know they need to be checked before merging.
#### Prerequisites
```bash
pip install -r superset/translations/requirements.txt
```
Claude Code must be installed and authenticated (`claude --version` should
work). The script calls `claude -p` internally — no separate API key is needed.
#### Step 1 — Build the translation index
The index captures every already-translated string in every language and
serves as cross-language context for the AI. Rebuild it whenever `.po` files
change significantly:
```bash
python scripts/translations/build_translation_index.py
# Writes: superset/translations/translation_index.json
```
#### Step 2 — Preview with a dry run
Check what would be translated without writing anything:
```bash
python scripts/translations/backfill_po.py --lang fr --limit 20 --dry-run
```
Output shows each string, its translation, and a context tag:
- No tag — 3+ reference languages available (high confidence)
- `[ctx:N]` — only N other languages have this string (lower confidence)
- `[ctx:0]` — no other language has this string yet; English alone used
#### Step 3 — Run the backfill
```bash
python scripts/translations/backfill_po.py --lang fr
```
Options:
| Flag | Default | Description |
|------|---------|-------------|
| `--lang LANG` | required | ISO language code (`fr`, `de`, `ja`, …) |
| `--batch-size N` | 50 | Strings per Claude request |
| `--limit N` | unlimited | Stop after N entries |
| `--min-context N` | 0 | Skip entries with fewer than N reference translations |
| `--model MODEL` | `claude-sonnet-4-6` | Claude model to use |
| `--dry-run` | off | Print without writing |
| `--no-fuzzy` | off | Don't mark entries as fuzzy |
Use `--min-context 2` to skip strings that have fewer than 2 reference
translations in other languages. Those strings are more likely to be ambiguous
(short labels, UI fragments) where the correct meaning can't be inferred
without additional context.
#### Step 4 — Review and commit
Open the target `.po` file and search for `fuzzy`. For each generated entry:
1. Verify the translation is correct for the UI context.
2. Remove the `# Machine-translated via backfill_po.py` comment and the
`#, fuzzy` flag line once you are satisfied.
3. If the translation is wrong, correct the `msgstr` before removing the flag.
4. Commit the `.po` file — do **not** commit `translation_index.json` (it is
gitignored and regenerated locally).
#### Running via npm
From `superset-frontend/`:
```bash
# Rebuild index
npm run translations:build-index
# Backfill (pass arguments after --)
npm run translations:backfill -- --lang fr --dry-run
```
## Linting
### Python

View File

@@ -110,7 +110,7 @@
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.4",
"webpack": "^5.107.0"
"webpack": "^5.107.1"
},
"browserslist": {
"production": [

View File

@@ -12270,14 +12270,7 @@ pvutils@^1.1.3, pvutils@^1.1.5:
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.5.tgz#84b0dea4a5d670249aa9800511804ee0b7c2809c"
integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==
qs@^6.12.3:
version "6.14.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c"
integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==
dependencies:
side-channel "^1.1.0"
qs@~6.15.1:
qs@^6.12.3, qs@~6.15.1:
version "6.15.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9"
integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
@@ -14964,10 +14957,10 @@ webpack-virtual-modules@^0.6.2:
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
webpack@^5.107.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.107.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.0.tgz#9e0d8d8baf24e76f058103f4f06ac6bb528b645a"
integrity sha512-PSxeHk/dmLYZlnTU+vL1Gej6Evg5RNtl3flhxBresfznFnzxinHMzHKloHnywM/3ouQv7/AlZCswWDIkNSggUA==
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
version "5.107.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
dependencies:
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"

View File

@@ -39,7 +39,7 @@ dependencies = [
"apache-superset-core",
"backoff>=1.8.0",
"celery>=5.3.6, <6.0.0",
"click>=8.0.3",
"click>=8.4.0",
"click-option-group",
"colorama",
"flask-cors>=6.0.0, <7.0",
@@ -58,7 +58,7 @@ dependencies = [
"flask-wtf>=1.1.0, <2.0",
"geopy",
"greenlet>=3.0.3, <=3.5.0",
"gunicorn>=22.0.0; sys_platform != 'win32'",
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
"holidays>=0.45, <1",
@@ -66,7 +66,7 @@ dependencies = [
"isodate",
"jsonpath-ng>=1.6.1, <2",
"Mako>=1.2.2",
"markdown>=3.0",
"markdown>=3.10.2",
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
"marshmallow>=3.0, <4",
"marshmallow-union>=0.1",
@@ -101,9 +101,9 @@ dependencies = [
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=28.10.0, <29",
"sqlglot>=30.8.0, <31",
# newer pandas needs 0.9+
"tabulate>=0.9.0, <1.0",
"tabulate>=0.10.0, <1.0",
"typing-extensions>=4, <5",
"waitress; sys_platform == 'win32'",
"watchdog>=6.0.0",
@@ -137,9 +137,9 @@ databricks = [
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.4, <2"]
drill = ["sqlalchemy-drill>=1.1.10, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
duckdb = ["duckdb>=1.5.2,<2", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
@@ -220,6 +220,7 @@ development = [
"openapi-spec-validator",
"parameterized",
"pip",
"polib", # used by scripts/translations/ and their unit tests
"pre-commit",
"progress>=1.5,<2",
"psutil",

View File

@@ -60,7 +60,7 @@ cffi==2.0.0
# pynacl
charset-normalizer==3.4.2
# via requests
click==8.2.1
click==8.4.1
# via
# apache-superset (pyproject.toml)
# celery
@@ -166,7 +166,7 @@ greenlet==3.1.1
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
gunicorn==25.3.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
@@ -213,7 +213,7 @@ mako==1.3.11
# -r requirements/base.in
# apache-superset (pyproject.toml)
# alembic
markdown==3.8.1
markdown==3.10.2
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -415,13 +415,13 @@ sqlalchemy-utils==0.42.0
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==28.10.0
sqlglot==30.8.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
tabulate==0.10.0
# via apache-superset (pyproject.toml)
trio==0.30.0
# via

View File

@@ -130,7 +130,7 @@ charset-normalizer==3.4.2
# via
# -c requirements/base-constraint.txt
# requests
click==8.2.1
click==8.4.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -219,7 +219,7 @@ docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
# via rich-rst
duckdb==1.4.2
duckdb==1.5.3
# via
# apache-superset
# duckdb-engine
@@ -388,7 +388,7 @@ grpcio==1.71.0
# grpcio-status
grpcio-status==1.60.1
# via google-api-core
gunicorn==23.0.0
gunicorn==25.3.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -511,7 +511,7 @@ mako==1.3.11
# -c requirements/base-constraint.txt
# alembic
# apache-superset
markdown==3.8.1
markdown==3.10.2
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -677,6 +677,8 @@ ply==3.11
# via
# -c requirements/base-constraint.txt
# jsonpath-ng
polib==1.2.0
# via apache-superset
polyline==2.0.2
# via
# -c requirements/base-constraint.txt
@@ -985,7 +987,7 @@ sqlalchemy-utils==0.42.0
# apache-superset
# apache-superset-core
# flask-appbuilder
sqlglot==28.10.0
sqlglot==30.8.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -1004,7 +1006,7 @@ statsd==4.0.1
# via apache-superset
syntaqlite==0.1.0
# via apache-superset
tabulate==0.9.0
tabulate==0.10.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -31,6 +31,7 @@ PATTERNS = {
r"^superset/",
r"^scripts/",
r"^setup\.py",
r"^pyproject\.toml$",
r"^requirements/.+\.txt",
r"^.pylintrc",
],

View File

@@ -0,0 +1,653 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Backfill missing translations in a .po file using Claude AI.
For each untranslated (empty msgstr) entry in the target language, the script
sends the English source string along with all available translations in other
languages to Claude as context, then writes the AI-generated translation back
into the .po file marked as #, fuzzy for human review.
Usage:
# Build the translation index first (one-time or when .po files change)
python scripts/translations/build_translation_index.py
# Backfill French translations
python scripts/translations/backfill_po.py --lang fr
# Dry run (print what would be translated without writing)
python scripts/translations/backfill_po.py --lang de --dry-run
# Limit to 100 entries and use a specific model
python scripts/translations/backfill_po.py --lang es --limit 100 \
--model claude-opus-4-6
Options:
--lang LANG ISO language code to backfill (required)
--batch-size N Number of strings per Claude request (default: 50)
--limit N Stop after translating N entries (default: unlimited)
--min-context N Skip entries with fewer than N existing translations across
reference languages (default: 0 — translate everything)
--model MODEL Claude model ID (default: claude-sonnet-4-6)
--index PATH Path to translation_index.json (default: auto-detect)
--dry-run Print translations without writing to .po file
--no-fuzzy Do not mark generated translations as fuzzy (default: mark fuzzy)
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
try:
import polib # type: ignore[import-untyped]
except ImportError:
print("polib is required. Run: pip install polib", file=sys.stderr)
sys.exit(1)
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
DEFAULT_INDEX = TRANSLATIONS_DIR / "translation_index.json"
DEFAULT_MODEL = "claude-sonnet-4-6"
DEFAULT_BATCH_SIZE = 50
# Language names for the prompt, keyed by ISO code
LANGUAGE_NAMES: dict[str, str] = {
"ar": "Arabic",
"ca": "Catalan",
"de": "German",
"es": "Spanish",
"fa": "Persian (Farsi)",
"fr": "French",
"it": "Italian",
"ja": "Japanese",
"ko": "Korean",
"mi": "Māori",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"pt_BR": "Brazilian Portuguese",
"ru": "Russian",
"sk": "Slovak",
"sl": "Slovenian",
"tr": "Turkish",
"uk": "Ukrainian",
"zh": "Chinese (Simplified)",
"zh_TW": "Chinese (Traditional)",
}
def _lang_name(code: str) -> str:
"""Return a human-readable language name for an ISO language code."""
return LANGUAGE_NAMES.get(code, code)
def _plural_key(msgid: str, msgid_plural: str) -> str:
"""Build the translation index key used for pluralized entries."""
return f"{msgid}\x00{msgid_plural}"
def _is_missing(entry: polib.POEntry) -> bool:
"""Return True for entries that need a translation."""
if entry.obsolete:
return False
if entry.msgid_plural:
return not any(v for v in entry.msgstr_plural.values())
return not entry.msgstr
def _context_langs(
item: dict[str, Any], index: dict[str, Any], target_lang: str
) -> list[str]:
"""Return sorted list of language codes that have translations for this entry."""
key = item["index_key"]
if key not in index:
return []
return sorted(
lang for lang, val in index[key].items() if lang != target_lang and val
)
def _context_count(
item: dict[str, Any], index: dict[str, Any], target_lang: str
) -> int:
"""Return the number of other-language translations available for this entry."""
return len(_context_langs(item, index, target_lang))
def _render_item(
i: int,
item: dict[str, Any],
index: dict[str, Any],
target_lang: str,
reference_langs_sorted: list[str],
) -> list[str]:
"""Render one batch entry as prompt lines."""
lines: list[str] = []
ctx = _context_count(item, index, target_lang)
if ctx == 0:
lines.append(
f"--- [{i}] (no reference translations — translate conservatively) ---"
)
else:
plural = "s" if ctx != 1 else ""
lines.append(f"--- [{i}] ({ctx} reference translation{plural}) ---")
lines.append(f"English: {json.dumps(item['msgid'], ensure_ascii=False)}")
if item.get("msgid_plural"):
plural_json = json.dumps(item["msgid_plural"], ensure_ascii=False)
lines.append(f"English plural: {plural_json}")
key = item["index_key"]
if key in index and reference_langs_sorted:
for lang in reference_langs_sorted:
val = index[key].get(lang)
if val is None:
continue
if isinstance(val, dict):
forms = "; ".join(
f"[{k}] {json.dumps(v, ensure_ascii=False)}" for k, v in val.items()
)
lines.append(f"{_lang_name(lang)}: {forms}")
else:
lines.append(
f"{_lang_name(lang)}: {json.dumps(val, ensure_ascii=False)}"
)
lines.append("")
return lines
def build_prompt(
target_lang: str,
batch: list[dict[str, Any]],
index: dict[str, Any],
) -> str:
"""Build the Claude prompt for a batch of entries."""
lang_name = _lang_name(target_lang)
# Collect which other languages actually have translations for this batch
reference_langs: set[str] = set()
for item in batch:
key = item["index_key"]
if key in index:
reference_langs.update(
lang for lang, val in index[key].items() if lang != target_lang and val
)
reference_langs_sorted = sorted(reference_langs)
lines: list[str] = [
"You are a professional translator specializing in software UI strings.",
f"Translate the following English strings into {lang_name} ({target_lang}).",
"",
"Rules:",
"- Preserve all format placeholders exactly: %(name)s, {name}, %s, %d, etc.",
"- Preserve HTML tags if present.",
"- Keep the same tone and register as the reference translations.",
"- For plural forms, provide translations for all plural forms"
" required by the language.",
"- Return ONLY a JSON object mapping each numeric index (as a string)"
" to its translation.",
"- Do not add any explanation, preamble, or markdown fences.",
"",
"Important: Many strings are short fragments or single words that are"
" ambiguous in English (e.g. 'Scale' could mean a measurement scale,"
" to scale an image, or fish scales). Use the translations in other"
" languages as your primary signal for which meaning is intended —"
" they collectively disambiguate the intended sense. When no"
" other-language translations are available for an entry, translate"
" conservatively based on the most common meaning in a data"
" visualization UI context.",
"",
]
if reference_langs_sorted:
lines.append(
f"Reference translations are provided per string where available "
f"({', '.join(_lang_name(lc) for lc in reference_langs_sorted)})."
)
lines.append("")
lines.append("Strings to translate:")
lines.append("")
for i, item in enumerate(batch):
lines.extend(_render_item(i, item, index, target_lang, reference_langs_sorted))
# Add guidance on plural form counts per language whenever ANY entry in
# the batch is plural — batches mix singular and plural in .po order, so
# gating on the first entry would silently drop the guidance whenever
# the plural entries happen to land after a singular one.
if any(item.get("msgid_plural") for item in batch):
lines.append(
"Note: provide ALL plural forms required by the target language "
"(e.g. French needs 2, Russian needs 3, Arabic needs 6)."
)
lines.append("")
lines.append(
'Expected output format: {"0": "<translation>", "1": "<translation>", ...}'
)
lines.append("(keys are the numeric indices of the strings above)")
return "\n".join(lines)
def parse_response(text: str, batch_size: int) -> dict[int, str]:
"""Parse the JSON object from Claude's response."""
# Strip any accidental markdown fences
text = re.sub(r"^```[^\n]*\n", "", text.strip())
text = re.sub(r"\n```$", "", text)
try:
raw = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(
f"Could not parse response as JSON: {exc}\n\nResponse:\n{text}"
) from exc
# _process_batches only catches ValueError/RuntimeError, so a non-object
# response (list, scalar, null) must surface as ValueError rather than
# bubbling up an AttributeError from .items() and aborting the whole run.
if not isinstance(raw, dict):
raise ValueError(
f"Expected a JSON object mapping indices to translations, "
f"got {type(raw).__name__}.\n\nResponse:\n{text}"
)
# Preserve dict/list values as JSON strings so plural responses (where
# v is a dict of plural forms) can be re-parsed downstream by
# _apply_translation's json.loads. str(v) on a dict produces Python
# repr ({'0': 'x'}) which is not valid JSON.
return {
int(k): (
json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else str(v)
)
for k, v in raw.items()
if str(k).isdigit()
}
def translate_batch(
model: str,
target_lang: str,
batch: list[dict[str, Any]],
index: dict[str, Any],
) -> dict[int, str]:
"""Send a batch of strings to Claude via `claude -p`.
Returns a dict mapping batch index to translated string.
"""
claude_bin = shutil.which("claude")
if not claude_bin:
raise RuntimeError(
"claude CLI not found. Install Claude Code or add it to PATH."
)
prompt = build_prompt(target_lang, batch, index)
# Pipe the prompt over stdin rather than passing it as argv: a single batch
# with many reference languages can grow into the tens of KB and approach
# ARG_MAX on some platforms.
# claude_bin is resolved via shutil.which — not user-controlled input
result = subprocess.run( # noqa: S603
[claude_bin, "--model", model, "-p"],
input=prompt,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
f"claude exited with code {result.returncode}:\n{result.stderr}"
)
return parse_response(result.stdout.strip(), len(batch))
def _apply_plural_translation(entry: polib.POEntry, translation: str) -> None:
"""Distribute a model response across the entry's plural forms.
Model may return a JSON dict ({"0": "form0", "1": "form1"}), a JSON list
(["form0", "form1"], also valid since plural forms are ordered), a JSON
scalar (a single translation that fills every form), or a plain non-JSON
string (older models that ignore the JSON instruction).
"""
try:
plural_value = json.loads(translation)
except (json.JSONDecodeError, ValueError):
for k in entry.msgstr_plural:
entry.msgstr_plural[k] = translation
return
if isinstance(plural_value, dict):
entry.msgstr_plural = {int(k): str(v) for k, v in plural_value.items()}
return
if isinstance(plural_value, list) and plural_value:
# Distribute list items across plural form indices in order; if the
# model returned fewer forms than the language requires, repeat the
# last form rather than leaving slots blank.
forms = [str(v) for v in plural_value]
for k in sorted(entry.msgstr_plural):
entry.msgstr_plural[k] = forms[k] if k < len(forms) else forms[-1]
return
# Scalar (or empty list) — broadcast to every form.
fill = str(plural_value) if plural_value not in (None, []) else translation
for k in entry.msgstr_plural:
entry.msgstr_plural[k] = fill
def _apply_translation(
entry: polib.POEntry,
translation: str,
item: dict[str, Any],
model: str,
mark_fuzzy: bool,
) -> None:
"""Write a translation string into a POEntry and add attribution."""
if entry.msgid_plural:
_apply_plural_translation(entry, translation)
else:
entry.msgstr = translation
if mark_fuzzy and "fuzzy" not in entry.flags:
entry.flags.append("fuzzy")
refs = item["context_langs"]
refs_tag = f" [refs: {', '.join(refs)}]" if refs else " [no refs]"
attribution = f"Machine-translated via backfill_po.py ({model}){refs_tag}"
if entry.tcomment:
if attribution not in entry.tcomment:
entry.tcomment = f"{entry.tcomment}\n{attribution}"
else:
entry.tcomment = attribution
def _build_batch_items(
entries: list[polib.POEntry],
index: dict[str, Any],
lang: str,
) -> list[dict[str, Any]]:
"""Convert a list of POEntries into the dict format used by translate_batch."""
items: list[dict[str, Any]] = []
for entry in entries:
if entry.msgid_plural:
item: dict[str, Any] = {
"msgid": entry.msgid,
"msgid_plural": entry.msgid_plural,
"index_key": _plural_key(entry.msgid, entry.msgid_plural),
"is_plural": True,
}
else:
item = {
"msgid": entry.msgid,
"index_key": entry.msgid,
"is_plural": False,
}
item["context_langs"] = _context_langs(item, index, lang)
item["context_count"] = len(item["context_langs"])
items.append(item)
return items
def _process_batches(
missing: list[polib.POEntry],
index: dict[str, Any],
lang: str,
batch_size: int,
model: str,
dry_run: bool,
mark_fuzzy: bool,
cat: polib.POFile | None = None,
po_path: Path | None = None,
) -> tuple[int, int]:
"""Translate missing entries in batches. Returns (translated, failed) counts.
When ``cat`` and ``po_path`` are provided and ``dry_run`` is False, the
catalog is saved to disk after each batch that produced at least one
successful translation. This means a crash mid-run only loses the in-flight
batch rather than every batch translated so far.
"""
translated_count = 0
failed_count = 0
for batch_start in range(0, len(missing), batch_size):
batch_entries = missing[batch_start : batch_start + batch_size]
batch_items = _build_batch_items(batch_entries, index, lang)
end = min(batch_start + batch_size, len(missing))
print(
f" Translating entries {batch_start + 1}{end} of {len(missing)}",
file=sys.stderr,
)
try:
translations = translate_batch(model, lang, batch_items, index)
except (ValueError, RuntimeError) as exc:
print(f" ERROR in batch starting at {batch_start}: {exc}", file=sys.stderr)
failed_count += len(batch_entries)
continue
batch_applied = 0
for i, entry in enumerate(batch_entries):
translation = translations.get(i)
if translation is None:
print(
f" WARNING: no translation returned for index {i} "
f"(msgid: {entry.msgid[:60]!r})",
file=sys.stderr,
)
failed_count += 1
continue
if dry_run:
ctx = batch_items[i]["context_count"]
ctx_tag = f" [ctx:{ctx}]" if ctx < 3 else ""
print(
f" [{lang}]{ctx_tag} {entry.msgid[:60]!r}{translation[:60]!r}"
)
else:
_apply_translation(
entry, translation, batch_items[i], model, mark_fuzzy
)
batch_applied += 1
translated_count += 1
if (
not dry_run
and batch_applied > 0
and cat is not None
and po_path is not None
):
cat.save()
print(
f" Saved {po_path} ({batch_applied} entry(ies) in this batch).",
file=sys.stderr,
)
return translated_count, failed_count
def backfill(
lang: str,
*,
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
min_context: int = 0,
model: str = DEFAULT_MODEL,
index_path: Path = DEFAULT_INDEX,
dry_run: bool = False,
mark_fuzzy: bool = True,
) -> None:
"""Backfill missing translations in the target language's .po file."""
# Defense against path traversal: ``lang`` lands in a filesystem path
# without further sanitization, so reject anything that isn't an
# ISO 639-1/639-2 code with an optional ISO 3166 region (e.g. ``pt_BR``).
if not re.fullmatch(r"[a-z]{2,3}(_[A-Z]{2})?", lang):
print(
f"Invalid language code: {lang!r} "
"(expected ISO 639 code, optionally with _<REGION>, e.g. 'fr' or 'pt_BR')",
file=sys.stderr,
)
sys.exit(1)
po_path = TRANSLATIONS_DIR / lang / "LC_MESSAGES" / "messages.po"
if not po_path.exists():
print(f"No .po file found for language '{lang}': {po_path}", file=sys.stderr)
sys.exit(1)
if not index_path.exists():
print(
f"Translation index not found at {index_path}.\n"
"Run: python scripts/translations/build_translation_index.py",
file=sys.stderr,
)
sys.exit(1)
print("Loading translation index …", file=sys.stderr)
with open(index_path, encoding="utf-8") as f:
index: dict[str, Any] = json.load(f)
print(f"Loading {po_path}", file=sys.stderr)
cat = polib.pofile(str(po_path))
missing: list[polib.POEntry] = [e for e in cat if e.msgid and _is_missing(e)]
print(f"Found {len(missing)} untranslated entries for '{lang}'.", file=sys.stderr)
if min_context > 0:
before = len(missing)
missing = [
e
for e in missing
if _context_count(
{
"index_key": (
_plural_key(e.msgid, e.msgid_plural)
if e.msgid_plural
else e.msgid
)
},
index,
lang,
)
>= min_context
]
skipped = before - len(missing)
print(
f"Skipping {skipped} entries with fewer than {min_context} reference "
f"translation(s) (use --min-context 0 to include them).",
file=sys.stderr,
)
if limit is not None:
missing = missing[:limit]
print(f"Limiting to {limit} entries.", file=sys.stderr)
if not missing:
print("Nothing to do.", file=sys.stderr)
return
translated_count, failed_count = _process_batches(
missing,
index,
lang,
batch_size,
model,
dry_run,
mark_fuzzy,
cat=cat,
po_path=po_path,
)
print(
f"\nDone. Translated: {translated_count}, Failed/skipped: {failed_count}.",
file=sys.stderr,
)
if not dry_run and translated_count > 0:
print(
f"Translations written to {po_path} (marked #, fuzzy for review).",
file=sys.stderr,
)
def main() -> None:
"""Parse CLI arguments and run translation backfill."""
parser = argparse.ArgumentParser(
description="Backfill missing .po translations using Claude AI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--lang", required=True, help="ISO language code (e.g. fr, de, ja)"
)
parser.add_argument(
"--batch-size",
type=int,
default=DEFAULT_BATCH_SIZE,
help=f"Strings per Claude request (default: {DEFAULT_BATCH_SIZE})",
)
parser.add_argument(
"--limit",
type=int,
default=None,
help="Maximum number of entries to translate (default: unlimited)",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=f"Claude model ID (default: {DEFAULT_MODEL})",
)
parser.add_argument(
"--index",
type=Path,
default=DEFAULT_INDEX,
help=f"Path to translation_index.json (default: {DEFAULT_INDEX})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print translations without modifying the .po file",
)
parser.add_argument(
"--min-context",
type=int,
default=0,
metavar="N",
help=(
"Skip entries with fewer than N reference translations in other languages "
"(default: 0 = translate everything). Strings with low context are more "
"likely to be ambiguous single words or fragments — set to e.g. 2 to only "
"translate strings that have been confirmed in at least 2 other languages."
),
)
parser.add_argument(
"--no-fuzzy",
dest="mark_fuzzy",
action="store_false",
default=True,
help=(
"Do not mark generated translations as #, fuzzy. "
"WARNING: fuzzy entries are excluded from compiled .mo files. "
"Removing this flag causes AI-generated translations to be served "
"to end users without human review — only use after you have "
"manually verified the .po file."
),
)
args = parser.parse_args()
backfill(
lang=args.lang,
batch_size=args.batch_size,
limit=args.limit,
min_context=args.min_context,
model=args.model,
index_path=args.index,
dry_run=args.dry_run,
mark_fuzzy=args.mark_fuzzy,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,153 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Build a cross-language translation index from all .po files.
Outputs a JSON file structured as:
{
"<msgid>": {
"<lang>": "<translated string or null>",
...
},
...
}
For plural entries the key is "<msgid>\x00<msgid_plural>" and the value
is a dict mapping lang -> {0: "...", 1: "..."} (or null if untranslated).
Usage:
python scripts/translations/build_translation_index.py
python scripts/translations/build_translation_index.py \
--translations-dir superset/translations \
--output /tmp/translation_index.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
try:
import polib # type: ignore[import-untyped]
except ImportError:
print("polib is required. Install with: pip install polib", file=sys.stderr)
sys.exit(1)
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
DEFAULT_OUTPUT = (
Path(__file__).parent.parent.parent
/ "superset"
/ "translations"
/ "translation_index.json"
)
def _is_translated(entry: polib.POEntry) -> bool:
"""Return True if the entry has a non-empty, non-fuzzy translation."""
if "fuzzy" in entry.flags:
return False
if entry.msgid_plural:
return any(v for v in entry.msgstr_plural.values())
return bool(entry.msgstr)
def _plural_key(entry: polib.POEntry) -> str:
"""Build the combined key used for plural translation entries."""
return f"{entry.msgid}\x00{entry.msgid_plural}"
def build_index(translations_dir: Path) -> dict[str, Any]:
"""Read all .po files and build a combined translation index."""
index: dict[str, dict[str, Any]] = {}
langs = sorted(
d
for d in os.listdir(translations_dir)
if (translations_dir / d / "LC_MESSAGES" / "messages.po").exists()
and d != "en" # en has empty msgstr by convention (source = target)
)
for lang in langs:
po_path = translations_dir / lang / "LC_MESSAGES" / "messages.po"
cat = polib.pofile(str(po_path))
for entry in cat:
if not entry.msgid:
continue # skip header entry
if entry.msgid_plural:
key = _plural_key(entry)
if key not in index:
index[key] = {}
# Fuzzy entries are unreviewed (often machine-generated drafts),
# so excluding them prevents feeding unverified translations
# back into the AI backfill prompt as trusted context.
index[key][lang] = (
dict(entry.msgstr_plural) if _is_translated(entry) else None
)
else:
key = entry.msgid
if key not in index:
index[key] = {}
index[key][lang] = entry.msgstr if _is_translated(entry) else None
# Ensure every entry has a slot for every language (null if missing)
for key in index:
for lang in langs:
index[key].setdefault(lang, None)
return index
def main() -> None:
"""Parse arguments, build the translation index, and write it to disk."""
parser = argparse.ArgumentParser(
description="Build cross-language translation index"
)
parser.add_argument(
"--translations-dir",
type=Path,
default=TRANSLATIONS_DIR,
help="Path to the translations directory (default: superset/translations)",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=DEFAULT_OUTPUT,
help=(
"Output JSON file path"
" (default: superset/translations/translation_index.json)"
),
)
args = parser.parse_args()
print(f"Reading .po files from {args.translations_dir}", file=sys.stderr)
index = build_index(args.translations_dir)
print(f"Indexed {len(index)} message IDs.", file=sys.stderr)
args.output.parent.mkdir(parents=True, exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(index, f, ensure_ascii=False, indent=2)
print(f"Written to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,250 @@
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Check that source-code changes don't cause translation regressions.
Usage
-----
Count non-fuzzy translated entries in all .po files and write JSON to stdout:
python check_translation_regression.py --count
Compare the current .po state against a previously-recorded baseline and fail
if any language lost translations:
python check_translation_regression.py --compare /path/to/before.json
Optionally write a markdown report to a file (used by CI to post a PR comment):
python check_translation_regression.py --compare before.json --report report.md
Use a translations directory other than the repo default (used by CI to count
against a separate base-branch worktree):
python check_translation_regression.py --count \\
--translations-dir /tmp/base-worktree/superset/translations
Typical CI workflow
-------------------
1. Create a base-branch worktree alongside the PR worktree
2. Run babel_update.sh in the base worktree (extract from BASE source)
3. Record baseline: python ... --count --translations-dir BASE_TREE > before.json
4. Run babel_update.sh in the PR worktree (extract from PR source) starting
from the same pristine BASE translations
5. Compare: python ... --compare before.json [--report report.md]
Comparing two babel_update outputs that started from the same BASE .po files
isolates regressions caused by the PR's source diff from any pre-existing
drift on the base branch.
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Optional
DEFAULT_TRANSLATIONS_DIR = (
Path(__file__).resolve().parent.parent.parent / "superset" / "translations"
)
# English .po files use empty msgstr by convention (source language == target),
# so they always show 0 translated entries and should not be checked.
SKIP_LANGS = {"en"}
def count_translated(po_file: Path) -> int:
"""Return the number of non-fuzzy translated messages in a .po file.
Raises:
subprocess.CalledProcessError: if ``msgfmt`` fails (e.g. malformed
.po file). The regression check exists to surface translation
problems, so a silent zero would defeat its purpose — let the
caller see a malformed file as a hard failure.
"""
import shutil # noqa: PLC0415
msgfmt = shutil.which("msgfmt") or "msgfmt"
result = subprocess.run( # noqa: S603
[msgfmt, "--statistics", "-o", "/dev/null", str(po_file)],
capture_output=True,
text=True,
check=True,
)
# stderr: "123 translated messages, 4 fuzzy translations, 56 untranslated messages."
match = re.search(r"(\d+) translated message", result.stderr)
if not match:
raise RuntimeError(
f"Could not parse msgfmt --statistics output for {po_file}: "
f"{result.stderr!r}"
)
return int(match.group(1))
def get_counts(translations_dir: Path) -> dict[str, int]:
counts: dict[str, int] = {}
for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")):
lang = po_file.parent.parent.name
if lang in SKIP_LANGS:
continue
try:
counts[lang] = count_translated(po_file)
except (subprocess.CalledProcessError, RuntimeError) as exc:
# A malformed .po file (msgfmt non-zero exit, or stderr we
# can't parse) is a real problem worth seeing, but it shouldn't
# take the whole regression check down with it — that would
# hide every other language's status. Skip and warn instead;
# the missing lang will not appear in the comparison output.
print(
f"WARNING: skipping {lang}{po_file} could not be counted: {exc}",
file=sys.stderr,
)
return counts
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
"""Build a markdown report for posting as a PR comment."""
rows = "\n".join(
f"| `{lang}` | {b} | {a} | -{b - a} |" for lang, b, a in regressions
)
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
return (
"## ⚠️ Translation Regression Detected\n\n"
f"This PR causes existing translations to become fuzzy or be removed "
f"in {affected}. Please fix the affected `.po` files before merging.\n\n"
"| Language | Before | After | Lost |\n"
"|----------|-------:|------:|-----:|\n"
f"{rows}\n\n"
"### How to fix\n\n"
"**1. Install dependencies** (if not already set up):\n\n"
"```bash\n"
"pip install -r superset/translations/requirements.txt\n"
"sudo apt-get install gettext # or: brew install gettext\n"
"```\n\n"
"**2. Re-extract strings and sync `.po` files:**\n\n"
"```bash\n"
"./scripts/translations/babel_update.sh\n"
"```\n\n"
"This rewrites `superset/translations/messages.pot` from the current "
"source files and merges the changes into every `.po` file. Strings "
"whose `msgid` changed will be marked `#, fuzzy`.\n\n"
f"**3. Resolve the fuzzy entries** in the affected language files "
f"({affected}):\n\n"
"```bash\n"
"grep -n '#, fuzzy' superset/translations/<lang>/LC_MESSAGES/messages.po\n"
"```\n\n"
"For each fuzzy entry, either rewrite the `msgstr` to match the new "
"string and remove the `#, fuzzy` line, or clear the `msgstr` to "
'`""` if you cannot provide a translation.\n\n'
"**4. Commit your changes to the `.po` files.**\n"
)
def cmd_count(translations_dir: Path) -> None:
counts = get_counts(translations_dir)
print(json.dumps(counts, indent=2))
def cmd_compare(
before_path: str,
translations_dir: Path,
report_path: Optional[str] = None,
) -> None:
with open(before_path) as f:
before: dict[str, int] = json.load(f)
after = get_counts(translations_dir)
regressions: list[tuple[str, int, int]] = []
for lang, before_count in sorted(before.items()):
after_count = after.get(lang, 0)
if after_count < before_count:
regressions.append((lang, before_count, after_count))
if regressions:
print("Translation regression detected!\n")
for lang, b, a in regressions:
lost = b - a
print(f" {lang}: {b} -> {a} (-{lost} string(s) became fuzzy or removed)")
print(
"\nStrings renamed or deleted by this PR invalidated existing translations."
)
print(
"Update the affected .po files to restore the lost entries before merging."
)
if report_path:
Path(report_path).write_text(
build_regression_report(regressions), encoding="utf-8"
)
sys.exit(1)
# All good — print a summary so it's easy to read in CI logs.
print("No translation regressions.\n")
for lang in sorted(after):
b = before.get(lang, 0)
a = after[lang]
if a > b:
delta = f"+{a - b}"
elif a == b:
delta = "no change"
else:
delta = f"-{b - a}"
print(f" {lang}: {b} -> {a} ({delta})")
def main() -> None:
parser = argparse.ArgumentParser(
description="Check for translation regressions in .po files."
)
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument(
"--count",
action="store_true",
help="Output translation counts per language as JSON.",
)
action.add_argument(
"--compare",
metavar="BEFORE_JSON",
help="Compare current counts against a baseline JSON file.",
)
parser.add_argument(
"--report",
metavar="REPORT_MD",
help="When --compare detects regressions, write a markdown report here.",
)
parser.add_argument(
"--translations-dir",
type=Path,
default=DEFAULT_TRANSLATIONS_DIR,
help=(
"Path to the translations directory containing per-language "
"LC_MESSAGES/messages.po files (default: <repo>/superset/translations)."
),
)
args = parser.parse_args()
if args.count:
cmd_count(args.translations_dir)
else:
cmd_compare(args.compare, args.translations_dir, args.report)
if __name__ == "__main__":
main()

View File

@@ -48,7 +48,7 @@ dependencies = [
"pydantic>=2.8.0",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=28.10.0, <29",
"sqlglot>=30.8.0, <31",
"typing-extensions>=4.0.0",
]

View File

@@ -1,100 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
waitForChartLoad,
ChartSpec,
getChartAliasesBySpec,
} from 'cypress/utils';
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { WORLD_HEALTH_CHARTS } from './utils';
import { isLegacyResponse } from '../../utils/vizPlugins';
describe('Dashboard top-level controls', () => {
beforeEach(() => {
cy.visit(WORLD_HEALTH_DASHBOARD);
});
// flaky test - query completes before assertion
it.skip('should allow chart level refresh', () => {
const mapSpec = WORLD_HEALTH_CHARTS.find(
({ viz }) => viz === 'world_map',
) as ChartSpec;
waitForChartLoad(mapSpec).then(gridComponent => {
const mapId = gridComponent.attr('data-test-chart-id');
cy.get('[data-test="grid-container"]').find('.world_map').should('exist');
cy.get(`#slice_${mapId}-controls`).click();
cy.get(`[data-test="slice_${mapId}-menu"]`)
.find('[data-test="refresh-chart-menu-item"]')
.click({ force: true });
// likely cause for flakiness:
// The query completes before this assertion happens.
// Solution: pause the network before clicking, assert, then unpause network.
cy.get('[data-test="refresh-chart-menu-item"]').should(
'have.class',
'ant-dropdown-menu-item-disabled',
);
waitForChartLoad(mapSpec);
cy.get('[data-test="refresh-chart-menu-item"]').should(
'not.have.class',
'ant-dropdown-menu-item-disabled',
);
});
});
it('should allow dashboard level force refresh', () => {
// when charts are not start loading, for example, under a secondary tab,
// should allow force refresh
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
getChartAliasesBySpec(WORLD_HEALTH_CHARTS).then(aliases => {
cy.get('[aria-label="ellipsis"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'not.have.class',
'ant-dropdown-menu-item-disabled',
);
cy.get('[data-test="refresh-dashboard-menu-item"]').click({
force: true,
});
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'have.class',
'ant-dropdown-menu-item-disabled',
);
// wait all charts force refreshed.
cy.wait(aliases).then(xhrs => {
xhrs.forEach(async ({ response, request }) => {
const responseBody = response?.body;
const isCached = isLegacyResponse(responseBody)
? responseBody.is_cached
: responseBody.result[0].is_cached;
// request url should indicate force-refresh operation
expect(request.url).to.have.string('force=true');
// is_cached in response should be false
expect(isCached).to.equal(false);
});
});
});
cy.get('[aria-label="ellipsis"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').and(
'not.have.class',
'ant-dropdown-menu-item-disabled',
);
});
});

View File

@@ -1,292 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { nativeFilters } from 'cypress/support/directories';
import {
addCountryNameFilter,
applyNativeFilterValueWithIndex,
enterNativeFilterEditModal,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
validateFilterNameOnDashboard,
testItems,
interceptFilterState,
} from './utils';
import {
prepareDashboardFilters,
SAMPLE_CHART,
visitDashboard,
} from './shared_dashboard_functions';
function openMoreFilters(waitFilterState = true) {
interceptFilterState();
// Wait for the dropdown button to appear when filters are overflowed
// The button only appears when there are overflowed filters
cy.getBySel('dropdown-container-btn', { timeout: 10000 })
.should('exist')
.should('be.visible')
.click({ force: true });
if (waitFilterState) {
cy.wait('@postFilterState');
}
}
function openVerticalFilterBar() {
cy.getBySel('dashboard-filters-panel').should('exist');
cy.getBySel('filter-bar__expand-button').click();
}
function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') {
cy.getBySel('filterbar-orientation-icon').click();
cy.wait(250);
cy.get('.filter-bar-orientation-submenu')
.contains('Orientation of filter bar')
.should('exist')
.trigger('mouseover');
if (orientation === 'vertical') {
cy.get('.ant-dropdown-menu-item-selected')
.contains('Horizontal (Top)')
.should('exist');
cy.get('.ant-dropdown-menu-item').contains('Vertical (Left)').click();
cy.getBySel('dashboard-filters-panel').should('exist');
} else {
cy.get('.ant-dropdown-menu-item-selected')
.contains('Vertical (Left)')
.should('exist');
cy.get('.ant-dropdown-menu-item').contains('Horizontal (Top)').click();
cy.getBySel('loading-indicator').should('exist');
cy.getBySel('filter-bar').should('exist');
cy.getBySel('dashboard-filters-panel').should('not.exist');
}
}
describe('Horizontal FilterBar', () => {
it('should go from vertical to horizontal and the opposite', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
setFilterBarOrientation('vertical');
});
it('should show all default actions in horizontal mode', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
cy.getBySel('horizontal-filterbar-empty')
.contains('No filters are currently added to this dashboard.')
.should('exist');
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.get('.ant-dropdown-menu').should('be.visible');
cy.getBySel('filter-bar__create-filter').should('exist');
cy.getBySel('filterbar-action-buttons').should('exist');
});
it('should stay in horizontal mode when reloading', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
cy.reload();
cy.getBySel('dashboard-filters-panel').should('not.exist');
});
it('should show all filters in available space on load', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
cy.get('.filter-item-wrapper').should('have.length', 3);
});
it('should show "more filters" on window resizing up and down', () => {
// Use 4 filters with unique columns to ensure overflow testing while allowing all to fit at large viewport
prepareDashboardFilters([
{ name: 'Country', column: 'country_name', datasetId: 2 },
{ name: 'Code', column: 'country_code', datasetId: 2 },
{ name: 'Region', column: 'region', datasetId: 2 },
{ name: 'Year', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
// At full width, check how many filters are visible in main bar
cy.get('.filter-item-wrapper').then($items => {
cy.log(`Found ${$items.length} filter items at full width`);
});
// Resize to force overflow
cy.viewport(500, 1024);
cy.wait(500); // Allow layout to stabilize after viewport change
// Should have some filters visible and dropdown button present
cy.get('.filter-item-wrapper').should('have.length.lessThan', 4);
cy.getBySel('dropdown-container-btn').should('exist');
// Open more filters and verify all are accessible in the dropdown
openMoreFilters(false);
// Check that the dropdown content contains filters
cy.getBySel('dropdown-content').within(() => {
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
});
// Close the dropdown
cy.getBySel('filter-bar').click();
// Test with medium viewport
cy.viewport(800, 1024);
cy.wait(500); // Allow layout to stabilize after viewport change
// May or may not have overflow at this size - test adaptively
cy.get('body').then($body => {
if ($body.find('[data-test="dropdown-container-btn"]').length > 0) {
openMoreFilters(false);
cy.getBySel('dropdown-content').within(() => {
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
});
cy.getBySel('filter-bar').click(); // Close dropdown
}
});
// At large viewport, all filters should fit
cy.viewport(1300, 1024);
cy.wait(500); // Allow layout to stabilize after viewport change
cy.get('.filter-item-wrapper').then($items => {
cy.log(`Found ${$items.length} filter items at large width`);
// Just verify we have some filters, don't assert exact count
expect($items.length).to.be.greaterThan(0);
});
cy.getBySel('dropdown-container-btn').should('not.exist');
});
it('should show "more filters" and scroll', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
{ name: 'test_4', column: 'year', datasetId: 2 },
{ name: 'test_5', column: 'country_name', datasetId: 2 },
{ name: 'test_6', column: 'country_code', datasetId: 2 },
{ name: 'test_7', column: 'region', datasetId: 2 },
{ name: 'test_8', column: 'year', datasetId: 2 },
{ name: 'test_9', column: 'country_name', datasetId: 2 },
{ name: 'test_10', column: 'country_code', datasetId: 2 },
{ name: 'test_11', column: 'region', datasetId: 2 },
{ name: 'test_12', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
cy.get('.filter-item-wrapper').should('have.length', 4);
openMoreFilters();
cy.getBySel('form-item-value').should('have.length', 12);
cy.getBySel('filter-control-name').contains('test_3').should('be.visible');
cy.getBySel('filter-control-name')
.contains('test_12')
.should('not.be.visible');
cy.getBySel('filter-control-name').contains('test_12').scrollIntoView();
cy.getBySel('filter-control-name').contains('test_12').should('be.visible');
});
it('should display newly added filter', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
enterNativeFilterEditModal(false);
addCountryNameFilter();
saveNativeFilterSettings([]);
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
});
it.skip('should spot changes in "more filters" and apply their values', () => {
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
{ name: 'test_4', column: 'year', datasetId: 2 },
{ name: 'test_5', column: 'country_name', datasetId: 2 },
{ name: 'test_6', column: 'country_code', datasetId: 2 },
{ name: 'test_7', column: 'region', datasetId: 2 },
{ name: 'test_8', column: 'year', datasetId: 2 },
{ name: 'test_9', column: 'country_name', datasetId: 2 },
{ name: 'test_10', column: 'country_code', datasetId: 2 },
{ name: 'test_11', column: 'region', datasetId: 2 },
{ name: 'test_12', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
openMoreFilters();
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
cy.get(nativeFilters.applyFilter).click({ force: true });
cy.wait('@chart');
cy.get('.ant-scroll-number.ant-badge-count').should(
'have.attr',
'title',
'1',
);
});
it.skip('should focus filter and open "more filters" programmatically', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
{ name: 'test_4', column: 'year', datasetId: 2 },
{ name: 'test_5', column: 'country_name', datasetId: 2 },
{ name: 'test_6', column: 'country_code', datasetId: 2 },
{ name: 'test_7', column: 'region', datasetId: 2 },
{ name: 'test_8', column: 'year', datasetId: 2 },
{ name: 'test_9', column: 'country_name', datasetId: 2 },
{ name: 'test_10', column: 'country_code', datasetId: 2 },
{ name: 'test_11', column: 'region', datasetId: 2 },
{ name: 'test_12', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
openMoreFilters();
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
cy.get(nativeFilters.applyFilter).click({ force: true });
cy.getBySel('slice-header').within(() => {
cy.get('.filter-counts').trigger('mouseover');
});
cy.getBySel('filter-status-popover').contains('test_9').click();
cy.getBySel('dropdown-content').should('be.visible');
cy.get('.ant-select-focused').should('be.visible');
});
it.skip('should show tag count and one plain tag on focus and only count on blur in select ', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
enterNativeFilterEditModal();
inputNativeFilterDefaultValue('Albania');
cy.get('.ant-select-selection-search-input').clear({ force: true });
inputNativeFilterDefaultValue('Algeria', true);
saveNativeFilterSettings([SAMPLE_CHART]);
cy.getBySel('filter-bar').within(() => {
cy.get(nativeFilters.filterItem).contains('Albania').should('be.visible');
cy.get(nativeFilters.filterItem).contains('+ 1 ...').should('be.visible');
cy.get('.ant-select-selection-search-input').click();
cy.get(nativeFilters.filterItem).contains('+ 2 ...').should('be.visible');
});
});
});

View File

@@ -1,53 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import qs from 'querystringify';
import { waitForChartLoad } from 'cypress/utils';
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { WORLD_HEALTH_CHARTS } from './utils';
interface QueryString {
native_filters_key: string;
}
describe('nativefilter url param key', () => {
// const urlParams = { param1: '123', param2: 'abc' };
let initialFilterKey: string;
it('should have cachekey in nativefilter param', () => {
// things in `before` will not retry and the `waitForChartLoad` check is
// especially flaky and may need more retries
cy.visit(WORLD_HEALTH_DASHBOARD);
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
cy.wait(1000); // wait for key to be published (debounced)
cy.location().then(loc => {
const queryParams = qs.parse(loc.search) as QueryString;
expect(typeof queryParams.native_filters_key).eq('string');
});
});
it('should have different key when page reloads', () => {
cy.visit(WORLD_HEALTH_DASHBOARD);
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
cy.wait(1000); // wait for key to be published (debounced)
cy.location().then(loc => {
const queryParams = qs.parse(loc.search) as QueryString;
expect(queryParams.native_filters_key).not.equal(initialFilterKey);
});
});
});

View File

@@ -1,51 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { waitForChartLoad } from 'cypress/utils';
import { WORLD_HEALTH_CHARTS, interceptLog } from './utils';
describe('Dashboard load', () => {
it('should load dashboard', () => {
cy.visit(WORLD_HEALTH_DASHBOARD);
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
});
it('should load in edit mode', () => {
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
cy.getBySel('discard-changes-button').should('be.visible');
});
it('should load in standalone mode', () => {
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
cy.get('#app-menu').should('not.exist');
});
it('should load in edit/standalone mode', () => {
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
cy.getBySel('discard-changes-button').should('be.visible');
cy.get('#app-menu').should('not.exist');
});
// TODO flaky test. skipping to unblock CI
it.skip('should send log data', () => {
interceptLog();
cy.visit(WORLD_HEALTH_DASHBOARD);
cy.wait('@logs', { timeout: 15000 });
});
});

View File

@@ -1,385 +0,0 @@
/**
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import qs from 'querystring';
import {
dashboardView,
nativeFilters,
dataTestChartName,
} from 'cypress/support/directories';
import {
addCountryNameFilter,
applyAdvancedTimeRangeFilterOnDashboard,
applyNativeFilterValueWithIndex,
cancelNativeFilterSettings,
deleteNativeFilter,
enterNativeFilterEditModal,
fillNativeFilterForm,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
undoDeleteNativeFilter,
validateFilterContentOnDashboard,
validateFilterNameOnDashboard,
testItems,
WORLD_HEALTH_CHARTS,
} from './utils';
import {
prepareDashboardFilters,
SAMPLE_CHART,
visitDashboard,
} from './shared_dashboard_functions';
// function selectFilter(index: number) {
// cy.get("[data-test='filter-title-container'] [draggable='true']")
// .eq(index)
// .click();
// }
// function closeFilterModal() {
// cy.get('body').then($body => {
// if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
// cy.getBySel('native-filter-modal-cancel-button').click();
// }
// });
// }
describe('Native filters', () => {
describe('Nativefilters initial state not required', () => {
it("User can check 'Filter has default value'", () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
});
it('User can add a new native filter', () => {
prepareDashboardFilters([]);
let filterKey: string;
const removeFirstChar = (search: string) =>
search.split('').slice(1, search.length).join('');
cy.location().then(loc => {
cy.url().should('contain', 'native_filters_key');
const queryParams = qs.parse(removeFirstChar(loc.search));
filterKey = queryParams.native_filters_key as string;
expect(typeof filterKey).eq('string');
});
enterNativeFilterEditModal();
addCountryNameFilter();
saveNativeFilterSettings([SAMPLE_CHART]);
cy.location().then(loc => {
cy.url().should('contain', 'native_filters_key');
const queryParams = qs.parse(removeFirstChar(loc.search));
const newfilterKey = queryParams.native_filters_key;
expect(newfilterKey).eq(filterKey);
});
cy.get(nativeFilters.modal.container).should('not.exist');
});
it('User can restore a deleted native filter', () => {
prepareDashboardFilters([
{ name: 'country_code', column: 'country_code', datasetId: 2 },
]);
enterNativeFilterEditModal();
cy.get(nativeFilters.filtersList.removeIcon).first().click();
cy.get('[data-test="restore-filter-button"]')
.should('be.visible')
.click();
cy.get(nativeFilters.modal.container)
.find(nativeFilters.filtersPanel.filterName)
.should(
'have.attr',
'value',
testItems.topTenChart.filterColumnCountryCode,
);
});
it('User can create a time grain filter', () => {
prepareDashboardFilters([]);
enterNativeFilterEditModal();
fillNativeFilterForm(
testItems.filterType.timeGrain,
testItems.filterType.timeGrain,
testItems.datasetForNativeFilter,
);
saveNativeFilterSettings([SAMPLE_CHART]);
applyNativeFilterValueWithIndex(0, testItems.filterTimeGrain);
cy.get(nativeFilters.applyFilter).click();
cy.url().then(u => {
const ur = new URL(u);
expect(ur.search).to.include('native_filters');
});
validateFilterNameOnDashboard(testItems.filterType.timeGrain);
validateFilterContentOnDashboard(testItems.filterTimeGrain);
});
it.skip('User can create a time range filter', () => {
enterNativeFilterEditModal();
fillNativeFilterForm(
testItems.filterType.timeRange,
testItems.filterType.timeRange,
);
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
cy.get(dashboardView.salesDashboardSpecific.vehicleSalesFilterTimeRange)
.should('be.visible')
.click();
applyAdvancedTimeRangeFilterOnDashboard('2005-12-17', '2006-12-17');
cy.url().then(u => {
const ur = new URL(u);
expect(ur.search).to.include('native_filters');
});
validateFilterNameOnDashboard(testItems.filterType.timeRange);
cy.get(nativeFilters.filterFromDashboardView.timeRangeFilterContent)
.contains('2005-12-17')
.should('be.visible');
});
it.skip('User can create a time column filter', () => {
enterNativeFilterEditModal();
fillNativeFilterForm(
testItems.filterType.timeColumn,
testItems.filterType.timeColumn,
testItems.datasetForNativeFilter,
);
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
cy.get(nativeFilters.modal.container).should('not.exist');
// assert that native filter is created
validateFilterNameOnDashboard(testItems.filterType.timeColumn);
applyNativeFilterValueWithIndex(
0,
testItems.topTenChart.filterColumnYear,
);
cy.get(nativeFilters.applyFilter).click({ force: true });
cy.wait('@chart');
validateFilterContentOnDashboard(testItems.topTenChart.filterColumnYear);
});
describe.only('Numerical Range Filter - Display Modes', () => {
beforeEach(() => {
visitDashboard();
});
const expandFilterConfiguration = () => {
cy.get('.ant-collapse-header')
.contains('Filter Configuration')
.should('be.visible')
.then($header => {
cy.wrap($header)
.closest('.ant-collapse-item')
.invoke('hasClass', 'ant-collapse-item-active')
.then(isExpanded => {
if (!isExpanded) cy.wrap($header).click();
});
});
cy.get('.ant-collapse-content-box').should('be.visible');
};
const selectRangeTypeOption = (label: string) => {
cy.contains('Range Type')
.should('be.visible')
.closest('.ant-form-item')
.within(() => {
cy.get('.ant-select-selector').click();
});
cy.get('.ant-select-dropdown:visible')
.contains('.ant-select-item-option', label)
.click();
};
const applyAndAssertInputs = (from: string, to: string) => {
// Set 'from' input
cy.get('[data-test="range-filter-from-input"]').clear();
cy.get('[data-test="range-filter-from-input"]').type(from);
cy.get('[data-test="range-filter-from-input"]').blur();
// Set 'to' input
cy.get('[data-test="range-filter-to-input"]').clear();
cy.get('[data-test="range-filter-to-input"]').type(to);
cy.get('[data-test="range-filter-to-input"]').blur();
// Assert values without chaining after .invoke()
cy.get('[data-test="range-filter-from-input"]')
.invoke('val')
.then(val => {
expect(val).to.equal(from);
});
cy.get('[data-test="range-filter-to-input"]')
.invoke('val')
.then(val => {
expect(val).to.equal(to);
});
};
it('User can create a numerical range filter with "Range Inputs" display mode', () => {
enterNativeFilterEditModal(false);
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
selectRangeTypeOption('Range Inputs');
saveNativeFilterSettings([]);
cy.wait(500); // allow filter to mount
applyAndAssertInputs('40', '70');
});
it('User can change the display mode to "Slider"', () => {
enterNativeFilterEditModal(false);
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
cy.contains('Range Type')
.should('be.visible')
.closest('.ant-form-item')
.within(() => {
cy.get('.ant-select-selector').click({ force: true });
});
cy.get('.ant-select-dropdown:visible .ant-select-item-option')
.contains(/^Slider$/)
.click({ force: true });
cy.get('.ant-select-selector').should('contain.text', 'Slider');
saveNativeFilterSettings([]);
cy.get('.ant-slider', { timeout: 10000 }).should('be.visible');
cy.get('[data-test="range-filter-from-input"]', {
timeout: 5000,
}).should('not.exist');
cy.get('[data-test="range-filter-to-input"]', { timeout: 5000 }).should(
'not.exist',
);
});
it('User can change the display mode to "Slider and range input"', () => {
enterNativeFilterEditModal(false);
// Re-create filter
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
selectRangeTypeOption('Slider and range input');
saveNativeFilterSettings([]);
cy.wait(500);
applyAndAssertInputs('40', '70');
});
});
it('User can undo deleting a native filter', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
undoDeleteNativeFilter();
cy.get(nativeFilters.modal.container)
.find(nativeFilters.filtersPanel.filterName)
.should('have.attr', 'value', testItems.topTenChart.filterColumn);
});
it('User can cancel changes in native filter', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
cy.getBySel('filters-config-modal__name-input').type('|EDITED', {
force: true,
});
cancelNativeFilterSettings();
enterNativeFilterEditModal(false);
cy.get(nativeFilters.filtersList.removeIcon).first().click();
cy.contains('You have removed this filter.').should('be.visible');
});
it('User can create a value filter', () => {
visitDashboard();
enterNativeFilterEditModal(false);
addCountryNameFilter();
cy.get(nativeFilters.filtersPanel.filterTypeInput)
.find(nativeFilters.filtersPanel.filterTypeItem)
.should('have.text', testItems.filterType.value);
saveNativeFilterSettings([]);
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
});
it('User can apply value filter with selected values', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
applyNativeFilterValueWithIndex(0, testItems.filterDefaultValue);
cy.get(nativeFilters.applyFilter).click();
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
});
it('User can stop filtering when filter is removed', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
cy.get(nativeFilters.filterItem)
.contains(testItems.filterDefaultValue)
.should('be.visible');
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
enterNativeFilterEditModal(false);
deleteNativeFilter();
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('be.visible');
});
});
});
});

View File

@@ -1,431 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
nativeFilters,
exploreView,
dataTestChartName,
} from 'cypress/support/directories';
import {
addParentFilterWithValue,
applyNativeFilterValueWithIndex,
cancelNativeFilterSettings,
checkNativeFilterTooltip,
clickOnAddFilterInModal,
collapseFilterOnLeftPanel,
enterNativeFilterEditModal,
expandFilterOnLeftPanel,
getNativeFilterPlaceholderWithIndex,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
nativeFilterTooltips,
validateFilterContentOnDashboard,
valueNativeFilterOptions,
validateFilterNameOnDashboard,
testItems,
} from './utils';
import {
prepareDashboardFilters,
SAMPLE_CHART,
visitDashboard,
} from './shared_dashboard_functions';
function selectFilter(index: number) {
cy.get("[data-test='filter-title-container'] [role='tab']").eq(index).click();
}
function closeFilterModal() {
cy.get('body').then($body => {
if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
cy.getBySel('native-filter-modal-cancel-button').click();
}
});
}
describe('Native filters', () => {
describe('Nativefilters tests initial state required', () => {
beforeEach(() => {
cy.createSampleDashboards([0]);
});
it.skip('Verify that default value is respected after revisit', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(nativeFilters.filterItem)
.contains(testItems.filterDefaultValue)
.should('be.visible');
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
// reload dashboard
cy.reload();
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
validateFilterContentOnDashboard(testItems.filterDefaultValue);
});
it('User can create parent filters using "Values are dependent on other filters"', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
saveNativeFilterSettings([SAMPLE_CHART]);
[
testItems.topTenChart.filterColumnRegion,
testItems.topTenChart.filterColumn,
].forEach(it => {
cy.get(nativeFilters.filterFromDashboardView.filterName)
.contains(it)
.should('be.visible');
});
getNativeFilterPlaceholderWithIndex(1)
.invoke('text')
.should('equal', '214 options', { timeout: 20000 });
// apply first filter value and validate 2nd filter is depden on 1st filter.
applyNativeFilterValueWithIndex(0, 'North America');
getNativeFilterPlaceholderWithIndex(0).should('have.text', '3 options', {
timeout: 20000,
});
});
it('user can delete dependent filter', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// remove year native filter to cause it disappears from parent filter input in global sales
cy.get(nativeFilters.modal.tabsList.removeTab)
.should('be.visible')
.first()
.click();
// make sure you are seeing global sales filter which had parent filter
cy.get(nativeFilters.modal.tabsList.filterItemsContainer)
.children()
.last()
.click();
//
cy.wait(1000);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters').should(
'not.exist',
);
},
);
});
it('user cannot create bi-directional dependencies between filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
{ name: 'country_code', column: 'country_code' },
{ name: 'year', column: 'year' },
]);
enterNativeFilterEditModal();
// First, make country_name dependent on region
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// Second, make country_code dependent on country_name
selectFilter(2);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumn);
// Now select region filter and try to add dependency
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
// Verify that only 'year' is available as dependency for region
// 'country_name' and 'country_code' should not be available (would create circular dependency)
cy.get('input[aria-label^="Limit type"]').click({ force: true });
cy.get('[role="listbox"]').should('be.visible');
cy.get('[role="listbox"]').should('contain', 'year');
cy.get('[role="listbox"]').should('not.contain', 'country_name');
cy.get('[role="listbox"]').should('not.contain', 'country_code');
cy.get('[role="listbox"]').contains('year').click();
},
);
});
it('Dependent filter selects first item based on parent filter selection', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
// cannot use saveNativeFilterSettings because there is a bug which
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
// to be saved when using dependent filters so,
// you reload the window.
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click({ force: true });
cy.get(nativeFilters.modal.container).should('not.exist');
cy.reload();
applyNativeFilterValueWithIndex(0, 'North America');
// Check that dependent filter auto-selects the first item
cy.get(nativeFilters.filterFromDashboardView.filterContent)
.eq(1)
.should('contain.text', 'Bermuda');
});
it('User can create filter depend on 2 other filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
{ name: 'country_code', column: 'country_code' },
]);
enterNativeFilterEditModal();
selectFilter(2);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
cy.get(exploreView.controlPanel.addFieldValue).click();
},
);
// add value to the first input
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// add value to the second input
addParentFilterWithValue(1, testItems.topTenChart.filterColumn);
saveNativeFilterSettings([SAMPLE_CHART]);
// filters should be displayed in the left panel
[
testItems.topTenChart.filterColumnRegion,
testItems.topTenChart.filterColumn,
testItems.topTenChart.filterColumnCountryCode,
].forEach(it => {
validateFilterNameOnDashboard(it);
});
// initially first filter shows 39 options
getNativeFilterPlaceholderWithIndex(0).should('have.text', '7 options');
// initially second filter shows 409 options
getNativeFilterPlaceholderWithIndex(1).should('have.text', '214 options');
// verify third filter shows 409 options
getNativeFilterPlaceholderWithIndex(2).should('have.text', '214 options');
// apply first filter value
applyNativeFilterValueWithIndex(0, 'North America');
// verify second filter shows 409 options available still
getNativeFilterPlaceholderWithIndex(0).should('have.text', '214 options');
// verify second filter shows 69 options available still
getNativeFilterPlaceholderWithIndex(1).should('have.text', '3 options');
// apply second filter value
applyNativeFilterValueWithIndex(1, 'United States');
// verify number of available options for third filter - should be decreased to only one
getNativeFilterPlaceholderWithIndex(0).should('have.text', '1 option');
});
it('User can remove parent filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(1);
// Select dependent option and auto use platform for genre
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
saveNativeFilterSettings([SAMPLE_CHART]);
enterNativeFilterEditModal(false);
cy.get(nativeFilters.modal.tabsList.removeTab)
.should('be.visible')
.first()
.click({
force: true,
});
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('be.visible');
});
});
});
describe('Nativefilters basic interactions', () => {
before(() => {
visitDashboard();
});
beforeEach(() => {
cy.createSampleDashboards([0]);
closeFilterModal();
});
it('User can expand / retract native filter sidebar on a dashboard', () => {
expandFilterOnLeftPanel();
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.get('.ant-dropdown-menu').should('be.visible');
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).should(
'be.visible',
);
cy.get(nativeFilters.filterFromDashboardView.expand).should(
'not.be.visible',
);
collapseFilterOnLeftPanel();
});
it('User can enter filter edit pop-up by clicking on native filter edit icon', () => {
enterNativeFilterEditModal(false);
});
it('User can delete a native filter', () => {
enterNativeFilterEditModal(false);
cy.get(nativeFilters.filtersList.removeIcon).first().click();
cy.contains('Restore filter').should('not.exist', { timeout: 10000 });
});
it('User can cancel creating a new filter', () => {
enterNativeFilterEditModal(false);
cancelNativeFilterSettings();
});
it('Verify setting options and tooltips for value filter', () => {
enterNativeFilterEditModal(false);
cy.contains('Filter value is required').scrollIntoView();
cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
cy.wait(300);
cy.contains('Filter value is required').should('be.visible').click({
force: true,
});
checkNativeFilterTooltip(0, nativeFilterTooltips.preFilter);
checkNativeFilterTooltip(1, nativeFilterTooltips.defaultValue);
cy.get(nativeFilters.modal.container).should('be.visible');
valueNativeFilterOptions.forEach(el => {
cy.contains(el);
});
cy.contains('Values are dependent on other filters').should('not.exist');
cy.get(
nativeFilters.filterConfigurationSections.checkedCheckbox,
).contains('Can select multiple values');
checkNativeFilterTooltip(2, nativeFilterTooltips.required);
checkNativeFilterTooltip(3, nativeFilterTooltips.defaultToFirstItem);
checkNativeFilterTooltip(4, nativeFilterTooltips.searchAllFilterOptions);
checkNativeFilterTooltip(5, nativeFilterTooltips.inverseSelection);
clickOnAddFilterInModal();
cy.contains('Values are dependent on other filters').should('exist');
});
});
});

View File

@@ -1,194 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
parsePostForm,
waitForChartLoad,
getChartAliasBySpec,
} from 'cypress/utils';
import { TABBED_DASHBOARD } from 'cypress/utils/urls';
import { expandFilterOnLeftPanel } from './utils';
const TREEMAP = { name: 'Treemap', viz: 'treemap_v2' };
const LINE_CHART = { name: 'Growth Rate', viz: 'echarts_timeseries_line' };
const BOX_PLOT = { name: 'Box plot', viz: 'box_plot' };
const BIG_NUMBER = { name: 'Number of Girls', viz: 'big_number_total' };
const TABLE = { name: 'Names Sorted by Num in California', viz: 'table' };
function topLevelTabs() {
cy.getBySel('dashboard-component-tabs')
.first()
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
.as('top-level-tabs');
}
function resetTabs() {
topLevelTabs();
cy.get('@top-level-tabs').first().click();
waitForChartLoad(TREEMAP);
waitForChartLoad(BIG_NUMBER);
waitForChartLoad(TABLE);
}
describe.skip('Dashboard tabs', () => {
before(() => {
cy.visit(TABBED_DASHBOARD);
});
beforeEach(() => {
resetTabs();
});
it('should switch tabs', () => {
topLevelTabs();
cy.get('@top-level-tabs').first().click();
cy.get('@top-level-tabs')
.first()
.should('have.class', 'ant-tabs-tab-active');
cy.get('@top-level-tabs')
.last()
.should('not.have.class', 'ant-tabs-tab-active');
cy.get('[data-test-chart-name="Box plot"]').should('not.exist');
cy.get('[data-test-chart-name="Trends"]').should('not.exist');
cy.get('@top-level-tabs').last().click();
cy.get('@top-level-tabs')
.last()
.should('have.class', 'ant-tabs-tab-active');
cy.get('@top-level-tabs')
.first()
.should('not.have.class', 'ant-tabs-tab-active');
waitForChartLoad(BOX_PLOT);
cy.get('[data-test-chart-name="Box plot"]').should('exist');
resetTabs();
// click row level tab, see 1 more chart
cy.getBySel('dashboard-component-tabs')
.eq(2)
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
.as('row-level-tabs');
cy.get('@row-level-tabs').last().click();
waitForChartLoad(LINE_CHART);
cy.get('[data-test-chart-name="Trends"]').should('exist');
cy.get('@row-level-tabs').first().click();
});
it.skip('should send new queries when tab becomes visible', () => {
// landing in first tab
waitForChartLoad(TREEMAP);
getChartAliasBySpec(TREEMAP).then(treemapAlias => {
// apply filter
cy.get('.Select__control').first().should('be.visible').click();
cy.get('.Select__control input[type=text]').first().focus();
cy.focused().type('South');
cy.get('.Select__option').contains('South Asia').click();
cy.get('.filter button:not(:disabled)').contains('Apply').click();
// send new query from same tab
cy.wait(treemapAlias).then(({ request }) => {
const requestBody = parsePostForm(request.body);
const requestParams = JSON.parse(requestBody.form_data as string);
expect(requestParams.extra_filters[0]).deep.eq({
col: 'region',
op: 'IN',
val: ['South Asia'],
});
});
});
cy.intercept('**/superset/explore_json/?*').as('legacyChartData');
// click row level tab, send 1 more query
cy.get('.ant-tabs-tab').contains('row tab 2').click();
cy.wait('@legacyChartData').then(({ request }) => {
const requestBody = parsePostForm(request.body);
const requestParams = JSON.parse(requestBody.form_data as string);
expect(requestParams.extra_filters[0]).deep.eq({
col: 'region',
op: 'IN',
val: ['South Asia'],
});
expect(requestParams.viz_type).eq(LINE_CHART.viz);
});
cy.intercept('POST', '**/api/v1/chart/data?*').as('v1ChartData');
// click top level tab, send 1 more query
cy.get('.ant-tabs-tab').contains('Tab B').click();
cy.wait('@v1ChartData').then(({ request }) => {
expect(request.body.queries[0].filters[0]).deep.eq({
col: 'region',
op: 'IN',
val: ['South Asia'],
});
});
getChartAliasBySpec(BOX_PLOT).then(boxPlotAlias => {
// navigate to filter and clear filter
cy.get('.ant-tabs-tab').contains('Tab A').click();
cy.get('.ant-tabs-tab').contains('row tab 1').click();
cy.get('.Select__clear-indicator').click();
cy.get('.filter button:not(:disabled)').contains('Apply').click();
// trigger 1 new query
waitForChartLoad(TREEMAP);
// make sure query API not requested multiple times
cy.on('fail', err => {
expect(err.message).to.include('timed out waiting');
return false;
});
cy.wait(boxPlotAlias, { timeout: 1000 }).then(() => {
throw new Error('Unexpected API call.');
});
});
});
it('should update size when switch tab', () => {
cy.get('@top-level-tabs').last().click();
cy.get('@top-level-tabs')
.last()
.should('have.class', 'ant-tabs-tab-active');
expandFilterOnLeftPanel();
cy.wait(1000);
cy.get('@top-level-tabs').first().click();
cy.get('@top-level-tabs')
.first()
.should('have.class', 'ant-tabs-tab-active');
cy.wait(1000);
cy.get("[data-test-viz-type='treemap_v2'] .chart-container").then(
$chartContainer => {
expect($chartContainer.get(0).scrollWidth).eq(
$chartContainer.get(0).offsetWidth,
);
},
);
});
});

View File

@@ -1,45 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { parsePostForm, JsonObject, waitForChartLoad } from 'cypress/utils';
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { WORLD_HEALTH_CHARTS } from './utils';
describe('Dashboard form data', () => {
const urlParams = { param1: '123', param2: 'abc' };
before(() => {
cy.visit(WORLD_HEALTH_DASHBOARD, { qs: urlParams });
});
it('should apply url params to slice requests', () => {
cy.intercept('**/api/v1/chart/data?*', request => {
// TODO: export url params to chart data API
request.body.queries.forEach((query: { url_params: JsonObject }) => {
expect(query.url_params).deep.eq(urlParams);
});
});
cy.intercept('**/superset/explore_json/*', request => {
const requestParams = JSON.parse(
parsePostForm(request.body).form_data as string,
);
expect(requestParams.url_params).deep.eq(urlParams);
});
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
});
});

View File

@@ -1,109 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
describe('AdhocFilters', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/v1/datasource/table/*/column/name/values').as(
'filterValues',
);
cy.intercept('POST', '**/superset/explore_json/**').as('postJson');
cy.intercept('GET', '**/superset/explore_json/**').as('getJson');
cy.visitChartByName('Boys'); // a table chart
cy.verifySliceSuccess({ waitAlias: '@postJson' });
});
let numScripts = 0;
it('Should load AceEditor scripts when needed', () => {
cy.get('script').then(nodes => {
numScripts = nodes.length;
});
cy.get('[data-test=adhoc_filters]').within(() => {
cy.get('.Select__control').scrollIntoView();
cy.get('.Select__control').click();
cy.get('input[type=text]').focus();
cy.focused().type('name{enter}');
cy.get("div[role='button']").first().click();
});
// antd tabs do lazy loading, so we need to click on tab with ace editor
cy.get('#filter-edit-popover').within(() => {
cy.get('.ant-tabs-tab').contains('Custom SQL').click();
cy.get('.ant-tabs-tab').contains('Simple').click();
});
cy.get('script').then(nodes => {
// should load new script chunks for SQL editor
expect(nodes.length).to.greaterThan(numScripts);
});
});
it('Set simple adhoc filter', () => {
cy.get('[aria-label="Comparator option"] .Select__control').click();
cy.get('[data-test=adhoc-filter-simple-value] input[type=text]').focus();
cy.focused().type('Jack{enter}', { delay: 20 });
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
cy.get(
'[data-test=adhoc_filters] .Select__control span.option-label',
).contains('name = Jack');
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@postJson',
chartSelector: 'svg',
});
});
it('Set custom adhoc filter', () => {
const filterType = 'name';
const filterContent = "'Amy' OR name = 'Donald'";
cy.get('[data-test=adhoc_filters] .Select__control').scrollIntoView();
cy.get('[data-test=adhoc_filters] .Select__control').click();
// remove previous input
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
cy.focused().type('{backspace}');
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
cy.focused().type(`${filterType}{enter}`);
cy.wait('@filterValues');
// selecting a new filter should auto-open the popup,
// so the tab should be visible by now
cy.get('#filter-edit-popover #adhoc-filter-edit-tabs-tab-SQL').click();
cy.get('#filter-edit-popover .ace_content').click();
cy.get('#filter-edit-popover .ace_text-input').type(filterContent);
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
// check if the filter was saved correctly
cy.get(
'[data-test=adhoc_filters] .Select__control span.option-label',
).contains(`${filterType} = ${filterContent}`);
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@postJson',
chartSelector: 'svg',
});
});
});

View File

@@ -1,123 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { interceptChart } from 'cypress/utils';
describe('AdhocMetrics', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('Clear metric and set simple adhoc metric', () => {
const metric = 'sum(num_girls)';
const metricName = 'Sum Girls';
cy.get('[data-test=metrics]')
.find('[data-test="remove-control-button"]')
.click();
cy.get('[data-test=metrics]')
.contains('Drop columns/metrics here or click')
.click();
// Title edit for saved metrics is disabled - switch to Simple
cy.get('[id="adhoc-metric-edit-tabs-tab-SIMPLE"]').click();
cy.get('[data-test="AdhocMetricEditTitle#trigger"]').click();
cy.get('[data-test="AdhocMetricEditTitle#input"]').type(metricName);
cy.get('input[aria-label="Select column"]').click();
cy.get('input[aria-label="Select column"]').type('num_girls{enter}');
cy.get('input[aria-label="Select aggregate options"]').click();
cy.get('input[aria-label="Select aggregate options"]').type('sum{enter}');
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
cy.get('[data-test="control-label"]').contains(metricName);
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: `${metric} AS "${metricName}"`, // SQL statement
});
});
xit('Switch from simple to custom sql', () => {
cy.get('[data-test=metrics]')
.find('[data-test="metric-option"]')
.should('have.length', 1);
// select column "num"
cy.get('[data-test=metrics]').find('.Select__clear-indicator').click();
cy.get('[data-test=metrics]').find('.Select__control').click();
cy.get('[data-test=metrics]').find('.Select__control input').type('num');
cy.get('[data-test=metrics]')
.find('.option-label')
.first()
.should('have.text', 'num')
.click();
// add custom SQL
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
cy.get('[data-test=metrics-edit-popover]').within(() => {
cy.get('.ace_content').click();
cy.get('.ace_text-input').type('/COUNT(DISTINCT name)', { force: true });
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
});
cy.get('button[data-test="run-query-button"]').click();
const metric = 'SUM(num)/COUNT(DISTINCT name)';
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: `${metric} AS "${metric}"`,
});
});
xit('Switch from custom sql tabs to simple', () => {
cy.get('[data-test=metrics]').within(() => {
cy.get('.Select__dropdown-indicator').click();
cy.get('input[type=text]').type('num_girls{enter}');
});
cy.get('[data-test=metrics]')
.find('[data-test="metric-option"]')
.should('have.length', 2);
cy.get('#metrics-edit-popover').within(() => {
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
cy.get('.ace_identifier').contains('num_girls');
cy.get('.ace_content').click();
cy.get('.ace_text-input').type('{selectall}{backspace}SUM(num)');
cy.get('#adhoc-metric-edit-tabs-tab-SIMPLE').click();
cy.get('.Select__single-value').contains(/^num$/);
cy.get('button').contains('Save').click();
});
cy.get('button[data-test="run-query-button"]').click();
const metric = 'SUM(num)';
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: `${metric} AS "${metric}"`,
});
});
});

View File

@@ -1,65 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { interceptV1ChartData } from './utils';
describe('Advanced analytics', () => {
beforeEach(() => {
interceptV1ChartData();
cy.intercept('PUT', '**/api/v1/explore/**').as('putExplore');
cy.intercept('GET', '**/explore/**').as('getExplore');
});
it('Create custom time compare', () => {
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@v1Data' });
cy.get('.ant-collapse-header')
.contains('Advanced analytics')
.click({ force: true });
cy.get('[data-test=time_compare]').find('.ant-select').click();
cy.get('[data-test=time_compare]')
.find('input[type=search]')
.type('28 days{enter}');
cy.get('[data-test=time_compare]').find('input[type=search]').clear();
cy.get('[data-test=time_compare]')
.find('input[type=search]')
.type('1 year{enter}');
cy.get('button[data-test="run-query-button"]').click();
cy.wait('@v1Data');
cy.wait('@putExplore');
cy.reload();
cy.verifySliceSuccess({
waitAlias: '@v1Data',
});
cy.wait('@getExplore');
cy.get('.ant-collapse-header')
.contains('Advanced analytics')
.click({ force: true });
cy.get('[data-test=time_compare]')
.find('.ant-select-selector')
.contains('28 days');
cy.get('[data-test=time_compare]')
.find('.ant-select-selector')
.contains('1 year');
});
});

View File

@@ -1,48 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { interceptChart } from 'cypress/utils';
describe('Annotations', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
it('Create formula annotation y-axis goal line', () => {
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
const layerLabel = 'Goal line';
// get by text Annotations and Layers
cy.get('span').contains('Annotations and Layers').click();
cy.get('[data-test=annotation_layers]').click();
cy.get('[data-test="popover-content"]').within(() => {
cy.get('[aria-label=Name]').type(layerLabel);
cy.get('[aria-label=Formula]').type('y=1400000');
cy.get('button').contains('OK').click();
});
cy.get('button[data-test="run-query-button"]').click();
cy.get('[data-test=annotation_layers]').contains(layerLabel);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
});

View File

@@ -1,192 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// ***********************************************
// Tests for links in the explore UI
// ***********************************************
import rison from 'rison';
import { nanoid } from 'nanoid';
import { interceptChart } from 'cypress/utils';
import { HEALTH_POP_FORM_DATA_DEFAULTS } from './visualizations/shared.helper';
const apiURL = (endpoint: string, queryObject: Record<string, unknown>) =>
`${endpoint}?q=${rison.encode(queryObject)}`;
describe('Test explore links', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
it('Open and close view query modal', () => {
cy.visitChartByName('Growth Rate');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[aria-label="Menu actions trigger"]').click();
cy.get('span').contains('View query').parent().click();
cy.wait('@chartData').then(() => {
cy.get('code');
});
cy.get('.ant-modal-content').within(() => {
cy.get('button.ant-modal-close').first().click({ force: true });
});
});
it('Test iframe link', () => {
cy.visitChartByName('Growth Rate');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[aria-label="Menu actions trigger"]').click();
cy.get('div[role="menuitem"]').within(() => {
cy.contains('Share').parent().click();
});
cy.getBySel('embed-code-button').click();
cy.get('#embed-code-popover').within(() => {
cy.get('textarea[name=embedCode]').contains('iframe');
});
});
it('Test chart save as AND overwrite', () => {
interceptChart({ legacy: false }).as('tableChartData');
const formData = {
...HEALTH_POP_FORM_DATA_DEFAULTS,
viz_type: 'table',
metrics: ['sum__SP_POP_TOTL'],
groupby: ['country_name'],
};
const newChartName = `Test chart [${nanoid()}]`;
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.url().then(() => {
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="saveas-radio"]').check();
cy.get('[data-test="new-chart-name"]').type(newChartName, {
force: true,
});
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.visitChartByName(newChartName);
// Overwriting!
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="save-overwrite-radio"]').check();
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
const query = {
filters: [
{
col: 'slice_name',
opr: 'eq',
value: newChartName,
},
],
};
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
expect(response.body.count).equals(1);
});
cy.deleteChartByName(newChartName, true);
});
});
it('Test chart save as and add to new dashboard', () => {
const chartName = 'Growth Rate';
const newChartName = `${chartName} [${nanoid()}]`;
const dashboardTitle = `Test dashboard [${nanoid()}]`;
cy.visitChartByName(chartName);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="saveas-radio"]').check();
cy.get('[data-test="new-chart-name"]').click();
cy.get('[data-test="new-chart-name"]').clear();
cy.get('[data-test="new-chart-name"]').type(newChartName);
// Add a new option using the "CreatableSelect" feature
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
.find('input[aria-label="Select a dashboard"]')
.type(`${dashboardTitle}`, { force: true });
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
force: true,
});
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData' });
let query = {
filters: [
{
col: 'dashboard_title',
opr: 'eq',
value: dashboardTitle,
},
],
};
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
expect(response.body.count).equals(1);
});
cy.visitChartByName(newChartName);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="save-overwrite-radio"]').check();
cy.get('[data-test="new-chart-name"]').click();
cy.get('[data-test="new-chart-name"]').clear();
cy.get('[data-test="new-chart-name"]').type(newChartName);
// This time around, typing the same dashboard name
// will select the existing one
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
.find('input[aria-label^="Select a dashboard"]')
.type(`${dashboardTitle}{enter}`, { force: true });
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
force: true,
});
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData' });
query = {
filters: [
{
col: 'slice_name',
opr: 'eq',
value: chartName,
},
],
};
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
expect(response.body.count).equals(1);
});
query = {
filters: [
{
col: 'dashboard_title',
opr: 'eq',
value: dashboardTitle,
},
],
};
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
expect(response.body.count).equals(1);
});
cy.deleteDashboardByName(dashboardTitle, true);
});
});

View File

@@ -1,80 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { interceptChart } from 'cypress/utils';
describe('Visualization > Big Number with Trendline', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
const BIG_NUMBER_FORM_DATA = {
datasource: '2__table',
viz_type: 'big_number',
slice_id: 42,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2000 : 2014-01-02',
metric: 'sum__SP_POP_TOTL',
adhoc_filters: [],
compare_lag: '10',
compare_suffix: 'over 10Y',
y_axis_format: '.3s',
show_trend_line: true,
start_y_axis_at_zero: true,
color_picker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
chartSelector: '.superset-legacy-chart-big-number',
});
}
it('should work', () => {
verify(BIG_NUMBER_FORM_DATA);
cy.get('.chart-container .header-line');
cy.get('.chart-container canvas');
});
it('should work without subheader', () => {
verify({
...BIG_NUMBER_FORM_DATA,
compare_lag: null,
});
cy.get('.chart-container .header-line');
cy.get('.chart-container .subtitle-line').should('not.exist');
cy.get('.chart-container canvas');
});
it('should not render trendline when hidden', () => {
verify({
...BIG_NUMBER_FORM_DATA,
show_trend_line: false,
});
cy.get('[data-test="chart-container"] .header-line');
cy.get('[data-test="chart-container"] canvas').should('not.exist');
});
});

View File

@@ -1,79 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { interceptChart } from 'cypress/utils';
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
describe('Visualization > Big Number Total', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
const BIG_NUMBER_DEFAULTS = {
...FORM_DATA_DEFAULTS,
viz_type: 'big_number_total',
};
it('Test big number chart with adhoc metric', () => {
const formData = { ...BIG_NUMBER_DEFAULTS, metric: NUM_METRIC };
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: NUM_METRIC.label,
});
});
it('Test big number chart with simple filter', () => {
const filters = [
{
expressionType: 'SIMPLE',
subject: 'name',
operator: 'IN',
comparator: ['Aaron', 'Amy', 'Andrea'],
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_4y6teao56zs_ebjsvwy48c',
},
];
const formData = {
...BIG_NUMBER_DEFAULTS,
metric: 'count',
adhoc_filters: filters,
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('Test big number chart ignores groupby', () => {
const formData = {
...BIG_NUMBER_DEFAULTS,
metric: NUM_METRIC,
groupby: ['state'],
};
cy.visitChartByParams(formData);
cy.wait(['@chartData']).then(async ({ response }) => {
cy.verifySliceContainer();
const responseBody = response?.body;
expect(responseBody.result[0].query).not.contains(formData.groupby[0]);
});
});
});

View File

@@ -1,65 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getDatasetId } from './shared.helper';
describe('Visualization > Box Plot', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const getBoxPlotFormData = datasetId => ({
datasource: `${datasetId}__table`,
viz_type: 'box_plot',
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '1960-01-01 : now',
metrics: ['sum__SP_POP_TOTL'],
adhoc_filters: [],
groupby: ['region'],
limit: '25',
color_scheme: 'bnbColors',
whisker_options: 'Min/max (no outliers)',
});
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work', () => {
getDatasetId('wb_health_population').then(datasetId => {
verify(getBoxPlotFormData(datasetId));
cy.get('.chart-container .box_plot canvas').should('have.length', 1);
});
});
it('should allow type to search color schemes', () => {
getDatasetId('wb_health_population').then(datasetId => {
verify(getBoxPlotFormData(datasetId));
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});
});

View File

@@ -1,108 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getDatasetId } from './shared.helper';
describe('Visualization > Bubble', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const getBubbleFormData = datasetId => ({
datasource: `${datasetId}__table`,
viz_type: 'bubble',
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2011-01-01 : 2011-01-02',
series: 'region',
entity: 'country_name',
x: 'sum__SP_RUR_TOTL_ZS',
y: 'sum__SP_DYN_LE00_IN',
size: 'sum__SP_POP_TOTL',
max_bubble_size: '50',
limit: 0,
color_scheme: 'bnbColors',
show_legend: true,
x_axis_label: '',
left_margin: 'auto',
x_axis_format: '.3s',
x_ticks_layout: 'auto',
x_log_scale: false,
x_axis_showminmax: false,
y_axis_label: '',
bottom_margin: 'auto',
y_axis_format: '.3s',
y_log_scale: false,
y_axis_showminmax: false,
});
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
it('should work with filter', () => {
getDatasetId('wb_health_population').then(datasetId => {
verify({
...getBubbleFormData(datasetId),
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: '==',
comparator: 'South Asia',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_b2tfg1rs8y_8kmrcyxvsqd',
},
],
});
cy.get('[data-test="chart-container"]').should('be.visible');
cy.get('[data-test="chart-container"]').within(() => {
cy.get('svg').find('.nv-point-clips circle').should('have.length', 8);
});
cy.get('[data-test="chart-container"]').then(nodeList => {
// Check that all circles have same color.
const color = nodeList[0].getAttribute('fill');
const circles = Array.prototype.slice.call(nodeList);
expect(circles.every(c => c.getAttribute('fill') === color)).to.equal(
true,
);
});
});
});
it('should allow type to search color schemes and apply the scheme', () => {
getDatasetId('wb_health_population').then(datasetId => {
cy.visitChartByParams(getBubbleFormData(datasetId));
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
cy.get('[data-test=run-query-button]').click();
cy.get('.bubble .nv-legend .nv-legend-symbol').should(
'have.css',
'fill',
'rgb(31, 168, 201)',
);
});
});
});

View File

@@ -1,100 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
describe('Visualization > Compare', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const COMPARE_FORM_DATA = {
datasource: '3__table',
viz_type: 'compare',
slice_id: 60,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metrics: ['count'],
adhoc_filters: [],
groupby: [],
order_desc: true,
contribution: false,
row_limit: 50000,
color_scheme: 'bnbColors',
x_axis_label: 'Frequency',
bottom_margin: 'auto',
x_ticks_layout: 'auto',
x_axis_format: 'smart_date',
x_axis_showminmax: false,
y_axis_label: 'Num',
left_margin: 'auto',
y_axis_showminmax: false,
y_log_scale: false,
y_axis_format: '.3s',
rolling_type: 'None',
comparison_type: 'values',
annotation_layers: [],
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
it('should work without groupby', () => {
verify(COMPARE_FORM_DATA);
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
});
it('should with group by', () => {
verify({
...COMPARE_FORM_DATA,
groupby: ['gender'],
});
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 2);
});
it('should work with filter', () => {
verify({
...COMPARE_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'gender',
operator: '==',
comparator: 'boy',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
},
],
});
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
});
it('should allow type to search color schemes and apply the scheme', () => {
verify(COMPARE_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});

View File

@@ -1,54 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
describe('Download Chart > Bar chart', () => {
const VIZ_DEFAULTS = {
...FORM_DATA_DEFAULTS,
viz_type: 'echarts_timeseries_bar',
};
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
it('download chart with image works', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: NUM_METRIC,
groupby: ['state'],
};
cy.visitChartByParams(formData);
cy.get('.header-with-actions .ant-dropdown-trigger').click();
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click();
cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(1) > .ant-dropdown-menu-submenu-title',
).click();
cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)',
).click();
cy.verifyDownload('.jpg', {
contains: true,
timeout: 25000,
interval: 600,
});
});
});

View File

@@ -1,75 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
describe('Visualization > Gauge', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const GAUGE_FORM_DATA = {
datasource: '3__table',
viz_type: 'gauge_chart',
metric: 'count',
adhoc_filters: [],
slice_id: 54,
row_limit: 10,
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work', () => {
verify(GAUGE_FORM_DATA);
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
});
it('should work with simple filter', () => {
verify({
...GAUGE_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'country_code',
operator: '==',
comparator: 'USA',
clause: 'WHERE',
sqlExpression: null,
isExtra: false,
isNew: false,
filterOptionName: 'filter_jaemvkxd5h_ku22m3wyo',
},
],
});
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
});
it('should allow type to search color schemes', () => {
verify(GAUGE_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('bnbColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
).should('exist');
});
});

View File

@@ -1,91 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
type adhocFilter = {
expressionType: string;
subject: string;
operator: string;
comparator: string;
clause: string;
sqlExpression: string | null;
filterOptionName: string;
};
describe('Visualization > Graph', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const GRAPH_FORM_DATA = {
datasource: '1__table',
viz_type: 'graph_chart',
slice_id: 55,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metric: 'sum__value',
adhoc_filters: [],
source: 'source',
target: 'target',
row_limit: 50000,
show_legend: true,
color_scheme: 'bnbColors',
};
function verify(formData: {
[name: string]: string | boolean | number | Array<adhocFilter>;
}): void {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work with ad-hoc metric', () => {
verify(GRAPH_FORM_DATA);
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
});
it('should work with simple filter', () => {
verify({
...GRAPH_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'source',
operator: '==',
comparator: 'Agriculture',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
},
],
});
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
});
it('should allow type to search color schemes', () => {
verify(GRAPH_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('bnbColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
).should('exist');
});
});

View File

@@ -1,82 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
describe('Visualization > Pie', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const PIE_FORM_DATA = {
datasource: '3__table',
viz_type: 'pie',
slice_id: 55,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metric: 'sum__num',
adhoc_filters: [],
groupby: ['gender'],
row_limit: 50000,
pie_label_type: 'key',
donut: false,
show_legend: true,
show_labels: true,
labels_outside: true,
color_scheme: 'bnbColors',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work with ad-hoc metric', () => {
verify(PIE_FORM_DATA);
cy.get('.chart-container .pie canvas').should('have.length', 1);
});
it('should work with simple filter', () => {
verify({
...PIE_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'gender',
operator: '==',
comparator: 'boy',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
},
],
});
cy.get('.chart-container .pie canvas').should('have.length', 1);
});
it('should allow type to search color schemes', () => {
verify(PIE_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});

View File

@@ -1,106 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
describe('Visualization > Pivot Table', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
});
const PIVOT_TABLE_FORM_DATA = {
datasource: '3__table',
viz_type: 'pivot_table_v2',
slice_id: 61,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metrics: ['sum__num'],
adhoc_filters: [],
groupbyRows: ['name'],
groupbyColumns: ['state'],
series_limit: 5000,
aggregateFunction: 'Sum',
rowTotals: true,
colTotals: true,
valueFormat: '.3s',
combineMetric: false,
};
const TEST_METRIC = {
expressionType: 'SIMPLE',
column: {
id: 338,
column_name: 'num_boys',
expression: '',
filterable: false,
groupby: false,
is_dttm: false,
type: 'BIGINT',
optionName: '_col_num_boys',
},
aggregate: 'SUM',
hasCustomLabel: false,
label: 'SUM(num_boys)',
optionName: 'metric_gvpdjt0v2qf_6hkf56o012',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
}
it('should work with single groupby', () => {
verify(PIVOT_TABLE_FORM_DATA);
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
});
it('should work with more than one groupby', () => {
verify({
...PIVOT_TABLE_FORM_DATA,
groupbyRows: ['name', 'gender'],
});
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
});
it('should work with multiple metrics', () => {
verify({
...PIVOT_TABLE_FORM_DATA,
metrics: ['sum__num', TEST_METRIC],
});
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
});
it('should work with multiple groupby and multiple metrics', () => {
verify({
...PIVOT_TABLE_FORM_DATA,
groupbyRows: ['name', 'gender'],
metrics: ['sum__num', TEST_METRIC],
});
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
});
});

View File

@@ -1,97 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
describe('Visualization > Sunburst', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
});
const SUNBURST_FORM_DATA = {
datasource: '2__table',
viz_type: 'sunburst_v2',
slice_id: 47,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: 'No filter',
columns: ['region'],
metric: 'sum__SP_POP_TOTL',
adhoc_filters: [],
row_limit: 50000,
color_scheme: 'bnbColors',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
}
// requires the ability to render charts using SVG only for tests
it.skip('should work without secondary metric', () => {
verify(SUNBURST_FORM_DATA);
cy.get('.chart-container svg g path').should('have.length', 7);
});
// requires the ability to render charts using SVG only for tests
it.skip('should work with secondary metric', () => {
verify({
...SUNBURST_FORM_DATA,
secondary_metric: 'sum__SP_RUR_TOTL',
});
cy.get('.chart-container svg g path').should('have.length', 7);
});
// requires the ability to render charts using SVG only for tests
it.skip('should work with multiple columns', () => {
verify({
...SUNBURST_FORM_DATA,
columns: ['region', 'country_name'],
});
cy.get('.chart-container svg g path').should('have.length', 221);
});
// requires the ability to render charts using SVG only for tests
it.skip('should work with filter', () => {
verify({
...SUNBURST_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: 'IN',
comparator: ['South Asia', 'North America'],
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_txje2ikiv6_wxmn0qwd1xo',
},
],
});
cy.get('.chart-container svg g path').should('have.length', 2);
});
it('should allow type to search color schemes', () => {
verify(SUNBURST_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});

View File

@@ -1,474 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { interceptChart } from 'cypress/utils';
import {
FORM_DATA_DEFAULTS,
NUM_METRIC,
MAX_DS,
MAX_STATE,
SIMPLE_FILTER,
} from './shared.helper';
// Table
describe('Visualization > Table', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
const VIZ_DEFAULTS = {
...FORM_DATA_DEFAULTS,
viz_type: 'table',
row_limit: 1000,
};
const PERCENT_METRIC = {
expressionType: 'SQL',
sqlExpression: 'CAST(SUM(num_girls) AS FLOAT)/SUM(num)',
column: null,
aggregate: null,
hasCustomLabel: true,
label: 'Girls',
optionName: 'metric_6qwzgc8bh2v_zox7hil1mzs',
};
it('Use default time column', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
granularity_sqla: undefined,
metrics: ['count'],
});
cy.get('[data-test=adhoc_filters]').contains('ds');
});
it('Format non-numeric metrics correctly', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P3M',
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
});
// when format with smart_date, time column use format by granularity
cy.get('.chart-container td:nth-child(1)').contains('2008 Q1');
// other column with timestamp use adaptive formatting
cy.get('.chart-container td:nth-child(3)').contains('2008');
cy.get('.chart-container td:nth-child(4)').contains('TX');
});
it('Format with table_timestamp_format', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P3M',
table_timestamp_format: '%Y-%m-%d %H:%M',
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
});
// time column and MAX(ds) metric column both use UTC time
cy.get('.chart-container td:nth-child(1)').contains('2008-01-01 00:00');
cy.get('.chart-container td:nth-child(3)').contains('2008-01-01 00:00');
cy.get('.chart-container td')
.contains('2008-01-01 08:00')
.should('not.exist');
// time column should not use time granularity when timestamp format is set
cy.get('.chart-container td').contains('2008 Q1').should('not.exist');
// other num numeric metric column should stay as string
cy.get('.chart-container td').contains('TX');
});
it('Test table with groupby', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: [NUM_METRIC, MAX_DS],
groupby: ['name'],
});
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: /GROUP BY.*name/i,
chartSelector: 'table',
});
});
it('Test table with groupby + time column', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P3M',
metrics: [NUM_METRIC, MAX_DS],
groupby: ['name'],
});
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
const records = response?.body.result[0].data;
// should sort by first metric when no sort by metric is set
expect(records[0][NUM_METRIC.label]).greaterThan(
records[1][NUM_METRIC.label],
);
});
// should handle frontend sorting correctly
cy.get('.chart-container th').contains('name').click();
cy.get('.chart-container td:nth-child(2):eq(0)').contains('Adam');
cy.get('.chart-container th').contains('ds').click();
cy.get('.chart-container th').contains('ds').click();
cy.get('.chart-container td:nth-child(1):eq(0)').contains('2008');
});
it('Test table with percent metrics and groupby', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
percent_metrics: PERCENT_METRIC,
metrics: [],
groupby: ['name'],
});
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Test table with groupby order desc', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: NUM_METRIC,
groupby: ['name'],
order_desc: true,
});
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Test table with groupby + order by + no metric', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: [],
groupby: ['name'],
timeseries_limit_metric: NUM_METRIC,
order_desc: true,
});
// should contain only the group by column
cy.get('.chart-container th').its('length').should('eq', 1);
// should order correctly
cy.get('.chart-container td:eq(0)').contains('Michael');
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Test table with groupby and limit', () => {
const limit = 10;
const formData = {
...VIZ_DEFAULTS,
metrics: NUM_METRIC,
groupby: ['name'],
row_limit: limit,
};
cy.visitChartByParams(formData);
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
expect(response?.body.result[0].data.length).to.eq(limit);
});
cy.get('[data-test="row-count-label"]').contains('10 rows');
});
it('Test table with columns and row limit', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
// should still work when query_mode is not-set/invalid
query_mode: undefined,
all_columns: ['state'],
metrics: [],
row_limit: 100,
});
// should display in raw records mode
cy.get(
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
).contains('Raw records');
cy.get('div[data-test="all_columns"]').should('be.visible');
cy.get('div[data-test="groupby"]').should('not.exist');
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
cy.get('[data-test="row-count-label"]').contains('100 rows');
// should allow switch back to aggregate mode
cy.get('div[data-test="query_mode"] .ant-radio-button-wrapper')
.contains('Aggregate')
.click();
cy.get(
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
).contains('Aggregate');
cy.get('div[data-test="all_columns"]').should('not.exist');
cy.get('div[data-test="groupby"]').should('be.visible');
});
it('Test table with columns, ordering, and row limit', () => {
const limit = 10;
const formData = {
...VIZ_DEFAULTS,
query_mode: 'raw',
all_columns: ['name', 'state', 'ds', 'num'],
metrics: [],
row_limit: limit,
order_by_cols: ['["num", false]'],
};
cy.visitChartByParams(formData);
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
const records = response?.body.result[0].data;
expect(records[0].num).greaterThan(records[records.length - 1].num);
});
});
it('Test table with simple filter', () => {
const metrics = ['count'];
const filters = [SIMPLE_FILTER];
const formData = { ...VIZ_DEFAULTS, metrics, adhoc_filters: filters };
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Tests table number formatting with % in metric name', () => {
const formData = {
...VIZ_DEFAULTS,
percent_metrics: PERCENT_METRIC,
groupby: ['state'],
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: /GROUP BY.*state/i,
chartSelector: 'table',
});
cy.get('td').contains(/\d*%/);
});
it('Test row limit with server pagination toggle', () => {
const serverPaginationSelector =
'[data-test="server_pagination-header"] div.pull-left [type="checkbox"]';
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
row_limit: 100,
});
// Enable server pagination
cy.get(serverPaginationSelector).click();
// Click row limit control and select high value (200k)
cy.get('div[aria-label="Row limit"]').click();
// Type 200000 and press enter to select the option
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('200000{enter}');
// Verify that there is no error tooltip when server pagination is enabled
cy.get('[data-test="error-tooltip"]').should('not.exist');
// Disable server pagination
cy.get(serverPaginationSelector).click();
// Verify error tooltip appears
cy.get('[data-test="error-tooltip"]').should('be.visible');
// Trigger mouseover and verify tooltip text
cy.get('[data-test="error-tooltip"]').trigger('mouseover');
// Verify tooltip content
cy.get('.ant-tooltip-inner').should('be.visible');
cy.get('.ant-tooltip-inner').should(
'contain',
'Server pagination needs to be enabled for values over',
);
// Hide the tooltip by adding display:none style
cy.get('.ant-tooltip').invoke('attr', 'style', 'display: none');
// Enable server pagination again
cy.get(serverPaginationSelector).click();
cy.get('[data-test="error-tooltip"]').should('not.exist');
cy.get('div[aria-label="Row limit"]').click();
// Type 1000000
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('1000000');
// Wait for 1 second
cy.wait(1000);
// Press enter
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('{enter}');
// Wait for error tooltip to appear and verify its content
cy.get('[data-test="error-tooltip"]')
.should('be.visible')
.trigger('mouseover');
// Wait for tooltip content and verify
cy.get('.ant-tooltip-inner').should('exist');
cy.get('.ant-tooltip-inner').should('be.visible');
// Verify tooltip content separately
cy.get('.ant-tooltip-inner').should('contain', 'Value cannot exceed');
});
it('Test sorting with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name'],
row_limit: 100000,
server_pagination: true, // Enable server pagination
});
// Wait for the initial data load
cy.wait('@chartData');
// Get the first column header (name)
cy.get('.chart-container th').contains('name').as('nameHeader');
// Click to sort ascending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'A'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Aa]/);
// Click again to sort descending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'Z'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Zz]/);
// Test numeric sorting
cy.get('.chart-container th').contains('COUNT').as('countHeader');
// Click to sort ascending by count
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify ascending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.most(second);
});
// Click again to sort descending
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify descending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.least(second);
});
});
it('Test search with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name', 'state'],
row_limit: 100000,
server_pagination: true,
include_search: true,
});
cy.wait('@chartData');
const searchInputSelector = '.dt-global-filter input';
// Basic search test
cy.get(searchInputSelector).should('be.visible');
cy.get(searchInputSelector).type('John');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/John/i);
});
// Clear and test case-insensitive search
cy.get(searchInputSelector).clear();
cy.wait('@chartData');
cy.get(searchInputSelector).type('mary');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Mary/i);
});
// Test special characters
cy.get(searchInputSelector).clear();
cy.get(searchInputSelector).type('Nicole');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Nicole/i);
});
// Test no results
cy.get(searchInputSelector).clear();
cy.get(searchInputSelector).type('XYZ123');
cy.wait('@chartData');
cy.get('.chart-container').contains('No records found');
// Test column-specific search
cy.get('.search-select').should('be.visible');
cy.get('.search-select').click();
cy.get('.ant-select-dropdown').should('be.visible');
cy.get('.ant-select-item-option').contains('state').should('be.visible');
cy.get('.ant-select-item-option').contains('state').click();
cy.get(searchInputSelector).clear();
cy.get(searchInputSelector).type('CA');
cy.wait('@chartData');
cy.wait(1000);
cy.get('td[aria-labelledby="header-state"]').should('be.visible');
cy.get('td[aria-labelledby="header-state"]')
.first()
.should('contain', 'CA');
});
});

View File

@@ -1,130 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
describe('Visualization > Time TableViz', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const VIZ_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'time_table' };
it('Test time series table multiple metrics last year total', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: [NUM_METRIC, 'count'],
column_collection: [
{
key: '9g4K-B-YL',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
},
],
url: '',
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
});
cy.get('[data-test="time-table"]').within(() => {
cy.get('span').contains('Sum(num)');
cy.get('span').contains('COUNT(*)');
});
});
it('Test time series table metric and group by last year total', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: [NUM_METRIC],
groupby: ['gender'],
column_collection: [
{
key: '9g4K-B-YL',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
},
],
url: '',
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
});
cy.get('[data-test="time-table"]').within(() => {
cy.get('td').contains('boy');
cy.get('td').contains('girl');
});
});
it('Test time series various time columns', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: [NUM_METRIC, 'count'],
column_collection: [
{ key: 'LHHNPhamU', label: 'Current', colType: 'time', timeLag: 0 },
{
key: '9g4K-B-YL',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
},
{
key: 'JVZXtNu7_',
label: 'YoY',
colType: 'time',
timeLag: 1,
comparisonType: 'perc',
d3format: '%',
},
{ key: 'tN5Gba36u', label: 'Trend', colType: 'spark' },
],
url: '',
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
});
cy.get('[data-test="time-table"]').within(() => {
cy.get('th').contains('Current');
cy.get('th').contains('Last Year');
cy.get('th').contains('YoY');
cy.get('th').contains('Trend');
cy.get('span').contains('%');
cy.get('svg')
.first()
.then(charts => {
const firstChart = charts[0];
expect(firstChart.clientWidth).greaterThan(0);
expect(firstChart.clientHeight).greaterThan(0);
});
});
});
});

View File

@@ -1,95 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
describe('Visualization > World Map', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const WORLD_MAP_FORM_DATA = {
datasource: '2__table',
viz_type: 'world_map',
slice_id: 45,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2014-01-01 : 2014-01-02',
entity: 'country_code',
country_fieldtype: 'cca3',
metric: 'sum__SP_RUR_TOTL_ZS',
adhoc_filters: [],
row_limit: 50000,
show_bubbles: true,
secondary_metric: 'sum__SP_POP_TOTL',
max_bubble_size: '25',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
it('should work with ad-hoc metric', () => {
verify(WORLD_MAP_FORM_DATA);
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 206);
});
it('should work with simple filter', () => {
verify({
...WORLD_MAP_FORM_DATA,
metric: 'count',
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: '==',
comparator: 'South Asia',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_8aqxcf5co1a_x7lm2d1fq0l',
},
],
});
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 8);
});
it('should hide bubbles when told so', () => {
verify({
...WORLD_MAP_FORM_DATA,
show_bubbles: false,
});
cy.get('.slice_container').then(containers => {
expect(
containers[0].querySelectorAll('.bubbles circle.datamaps-bubble')
.length,
).to.equal(0);
});
});
it('should allow type to search color schemes', () => {
verify(WORLD_MAP_FORM_DATA);
cy.get('.Control[data-test="linear_color_scheme"]').scrollIntoView();
cy.get(
'.Control[data-test="linear_color_scheme"] input[type="search"]',
).focus();
cy.focused().type('greens{enter}');
cy.get(
'.Control[data-test="linear_color_scheme"] .ant-select-selection-item [data-test="greens"]',
).should('exist');
});
});

View File

@@ -39,7 +39,7 @@
// oxlint versions (not actually enforced). Documented here for future
// maintainers — if/when oxlint adds them, re-enable in the relevant
// plugin section above.
// import: newline-after-import, no-extraneous-dependencies,
// import: no-extraneous-dependencies,
// no-import-module-exports, no-relative-packages,
// no-unresolved, no-useless-path-segments
// react: default-props-match-prop-types, destructuring-assignment,
@@ -47,7 +47,6 @@
// forbid-prop-types, function-component-definition,
// jsx-no-bind, jsx-uses-vars, no-access-state-in-setstate,
// no-deprecated, no-did-update-set-state, no-typos,
// no-unstable-nested-components,
// no-unused-class-component-methods, no-unused-prop-types,
// no-unused-state, prefer-stateless-function, prop-types,
// require-default-props, sort-comp, static-property-placement
@@ -137,6 +136,7 @@
"import/no-self-import": "error",
"import/no-cycle": "off",
"import/prefer-default-export": "off",
"import/newline-after-import": "error",
// === React plugin rules ===
"react/jsx-filename-extension": [
@@ -184,6 +184,10 @@
"error",
{ "button": true, "submit": true, "reset": false }
],
// TODO: Graduate to "error" after cleanup pass — ~150 violations
// across the codebase require hoisting nested component definitions
// out of their parent render functions.
"react/no-unstable-nested-components": "warn",
// === React Hooks rules ===
// TODO: Fix conditional hook usage and anonymous component issues
@@ -271,7 +275,10 @@
},
"overrides": [
{
"files": ["plugins/plugin-chart-table/src/TableChart.tsx", "plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"],
"files": [
"plugins/plugin-chart-table/src/TableChart.tsx",
"plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"
],
"rules": {
"jsx-a11y/no-redundant-roles": "off"
}

View File

@@ -31,7 +31,7 @@
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@googleapis/sheets": "^13.0.1",
"@googleapis/sheets": "^13.0.2",
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
@@ -96,7 +96,7 @@
"fs-extra": "^11.3.5",
"fuse.js": "^7.3.0",
"geolib": "^3.3.14",
"geostyler": "^18.5.1",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
@@ -121,7 +121,7 @@
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react-arborist": "^3.6.1",
"react-arborist": "^3.7.0",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
@@ -195,7 +195,7 @@
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.33",
"@swc/plugin-emotion": "^14.9.0",
"@swc/plugin-emotion": "^14.10.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -229,7 +229,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.29",
"baseline-browser-mapping": "^2.10.31",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -284,14 +284,14 @@
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.0",
"ts-jest": "^29.4.10",
"ts-jest": "^29.4.11",
"tscw-config": "^1.1.2",
"tsx": "^4.22.3",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.10",
"webpack": "^5.106.2",
"webpack": "^5.107.1",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",
@@ -3958,9 +3958,9 @@
}
},
"node_modules/@googleapis/sheets": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
"integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==",
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.2.tgz",
"integrity": "sha512-b1tBlMcfvNEziM4DZCikLOc9iqSlgCK1e5bMKtNQIADRXr1CQmbkHV3ZBVvTsFsjLErgihqO58Itn/kzCnSZ0A==",
"license": "Apache-2.0",
"dependencies": {
"googleapis-common": "^8.0.0"
@@ -12568,9 +12568,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "14.9.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.9.0.tgz",
"integrity": "sha512-h57mL/TsOrhimvHs6KQQLZO1T+D7FQyx+7WS17p9vV228qxmZatF0IgEXMyERWthm1QL7fAB6cEMBCtujSVbyw==",
"version": "14.10.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.10.0.tgz",
"integrity": "sha512-uhPq0oJHk2/W2Hn6vLaNmbUUgNPPj0FINHISxfs9hqS2Hpv/TVzQFsnbxul1FJEa+YQe1Qebou2esDphwzIuKg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -13164,22 +13164,13 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
}
},
"node_modules/@types/eslint-scope": {
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -17218,9 +17209,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
"version": "2.10.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -21827,14 +21818,14 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
@@ -24541,9 +24532,9 @@
"license": "MIT"
},
"node_modules/geostyler": {
"version": "18.5.1",
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.1.tgz",
"integrity": "sha512-5+vLuDo1oR4QQTnrfkccIQSe3qEn0ytV9dLiFFhnxhPdziv/Wp3vKNhJZ37MUF5yIj2ISWZ+q/VmSNH6ifvWpg==",
"version": "18.6.0",
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.6.0.tgz",
"integrity": "sha512-q8x5V4yJlTFOIe5LSvhEHd62MrMJq1YXWJVTeAG2TUMgOudjrcglXDqKtFYtEdWHeORH6TXz7q+m6cg3RlZqAg==",
"license": "BSD-2-Clause",
"dependencies": {
"@ant-design/icons": "^5.5.1",
@@ -32537,9 +32528,9 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
"integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -40280,9 +40271,9 @@
}
},
"node_modules/react-arborist": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.6.1.tgz",
"integrity": "sha512-h2/sPz6PXL79h7mOWjCA6Y5WNUKmA0kL8Uh6RYZQbYk7UOFBd86Jeoga4RjHMBYpOWpBPYrOJOE3HbIPUETp8w==",
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.7.0.tgz",
"integrity": "sha512-gh2SoO0eXQVSP6zxXMGqFeXF+l2uabDGBVn0+RKqy/s7mrG5xGnfM5mhyB67cMVobC3vWYLqe6HGh7ZEZadW/w==",
"license": "MIT",
"dependencies": {
"react-dnd": "^14.0.3",
@@ -44459,9 +44450,9 @@
}
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -45223,9 +45214,9 @@
}
},
"node_modules/ts-jest": {
"version": "29.4.10",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
"version": "29.4.11",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz",
"integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -47366,13 +47357,12 @@
}
},
"node_modules/webpack": {
"version": "5.106.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz",
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
"version": "5.107.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.1.tgz",
"integrity": "sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
@@ -47382,20 +47372,20 @@
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.20.0",
"es-module-lexer": "^2.0.0",
"enhanced-resolve": "^5.21.4",
"es-module-lexer": "^2.1.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"loader-runner": "^4.3.1",
"loader-runner": "^4.3.2",
"mime-db": "^1.54.0",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.17",
"terser-webpack-plugin": "^5.5.0",
"watchpack": "^2.5.1",
"webpack-sources": "^3.3.4"
"webpack-sources": "^3.4.1"
},
"bin": {
"webpack": "bin/webpack.js"
@@ -48910,7 +48900,7 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.2.2",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -49983,7 +49973,7 @@
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.1"
"zod": "^4.4.3"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -50095,7 +50085,7 @@
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.24.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"react-map-gl": "^8.1.0",
"supercluster": "^8.0.1"
},
"peerDependencies": {
@@ -50184,7 +50174,7 @@
"license": "Apache-2.0",
"dependencies": {
"@types/d3-scale": "^4.0.9",
"d3-cloud": "^1.2.9",
"d3-cloud": "^1.2.8",
"d3-scale": "^4.0.2"
},
"devDependencies": {

View File

@@ -43,6 +43,8 @@
"build-instrumented": "cross-env NODE_ENV=production BABEL_ENV=instrumented webpack --mode=production --color",
"build-storybook": "storybook build",
"build-translation": "scripts/po2json.sh",
"translations:build-index": "python3 ../scripts/translations/build_translation_index.py",
"translations:backfill": "python3 ../scripts/translations/backfill_po.py",
"bundle-stats": "cross-env BUNDLE_ANALYZER=true npm run build && npx open-cli ../superset/static/stats/statistics.html",
"clear-npm": "mkdir -p /tmp/empty && rsync -a --delete /tmp/empty/ node_modules/ && rmdir node_modules /tmp/empty",
"core:cover": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage --coverageThreshold='{\"global\":{\"statements\":100,\"branches\":100,\"functions\":100,\"lines\":100}}' --collectCoverageFrom='[\"packages/**/src/**/*.{js,ts}\", \"!packages/superset-core/**/*\"]' packages",
@@ -112,7 +114,7 @@
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@googleapis/sheets": "^13.0.1",
"@googleapis/sheets": "^13.0.2",
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
@@ -177,7 +179,7 @@
"fs-extra": "^11.3.5",
"fuse.js": "^7.3.0",
"geolib": "^3.3.14",
"geostyler": "^18.5.1",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
@@ -202,7 +204,7 @@
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react-arborist": "^3.6.1",
"react-arborist": "^3.7.0",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
@@ -276,7 +278,7 @@
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.33",
"@swc/plugin-emotion": "^14.9.0",
"@swc/plugin-emotion": "^14.10.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -310,7 +312,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.29",
"baseline-browser-mapping": "^2.10.31",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -365,14 +367,14 @@
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.0",
"ts-jest": "^29.4.10",
"ts-jest": "^29.4.11",
"tscw-config": "^1.1.2",
"tsx": "^4.22.3",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.10",
"webpack": "^5.106.2",
"webpack": "^5.107.1",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",

View File

@@ -30,7 +30,7 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.2.2",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {

View File

@@ -48,6 +48,7 @@ import NoResultsComponent from './NoResultsComponent';
import { isMatrixifyEnabled } from '../types/matrixify';
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
export type WrapperProps = Dimension & {

View File

@@ -897,6 +897,476 @@ test('fires onChange when pasting a selection', async () => {
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
});
test('replaces cached options with search results instead of merging', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const searchData = [{ label: 'Search Match', value: 100 }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: searchData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
let options = await findAllSelectOptions();
expect(options).toHaveLength(10);
await type('search');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Search Match');
});
test('shows all options when filterOption is false', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Base ${i}`,
value: i,
}));
const searchData = Array.from({ length: 5 }, (_, i) => ({
label: `Server ${i}`,
value: 100 + i,
}));
const loadOptions = jest.fn(async (search: string) =>
search === ''
? { data: page0Data, totalCount: 100 }
: { data: searchData, totalCount: 5 },
);
render(
<AsyncSelect
{...defaultProps}
options={loadOptions}
filterOption={false}
/>,
);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('zzz_no_match');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
const options = await findAllSelectOptions();
expect(options).toHaveLength(5);
expect(options[0]).toHaveTextContent('Server 0');
});
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: [], totalCount: 0 };
});
render(
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('newval');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('newval');
// Stale page-0 options must not bleed through.
expect(screen.queryByText('Option 0')).not.toBeInTheDocument();
});
test('restores base options when search is cleared', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const searchData = [{ label: 'Search Match', value: 100 }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: searchData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('search');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
let options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Search Match');
// type() clears the input before typing, so passing '' clears the search.
await type('');
await waitFor(async () => {
options = await findAllSelectOptions();
expect(options).toHaveLength(10);
});
expect(options[0]).toHaveTextContent('Option 0');
expect(screen.queryByText('Search Match')).not.toBeInTheDocument();
});
test('replaces results when switching between two searches', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return {
data: [{ label: `Match-${search}`, value: `v-${search}` }],
totalCount: 1,
};
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('alpha');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-alpha');
});
await type('beta');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-beta');
});
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument();
});
test('refetches a dropped search response when the same search is repeated', async () => {
type OptionRow = { label: string; value: string | number };
type PageResponse = { data: OptionRow[]; totalCount: number };
// Resolves the in-flight loadOptions promise of the calling test.
let resolveAlpha: ((value: PageResponse) => void) | null = null;
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
const loadOptions = jest.fn((search: string) => {
if (search === '') {
return Promise.resolve<PageResponse>({
data: page0Data,
totalCount: 100,
});
}
if (search === 'alpha') {
// First call: hold the promise so it resolves only after beta returns.
// Second call (after beta): resolve immediately so the cache MUST allow
// a refetch.
if (!resolveAlpha) {
return new Promise<PageResponse>(resolve => {
resolveAlpha = resolve;
});
}
return Promise.resolve<PageResponse>({ data: alphaData, totalCount: 1 });
}
return Promise.resolve<PageResponse>({ data: betaData, totalCount: 1 });
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
await type('alpha');
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10));
// alpha's promise is held; switch to beta which resolves first.
await type('beta');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-beta');
});
// Release the stale alpha response. It must be dropped — its key must not
// be cached, or returning to "alpha" later would short-circuit the fetch.
resolveAlpha!({ data: alphaData, totalCount: 1 });
await waitFor(async () => {
// Beta is still showing because alpha's response was dropped.
const options = await findAllSelectOptions();
expect(options[0]).toHaveTextContent('Match-beta');
});
// Returning to "alpha" must re-trigger the fetch (cache wasn't poisoned).
const callsBeforeAlphaReturn = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
await type('alpha');
await waitFor(() => {
const callsAfter = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
expect(callsAfter).toBeGreaterThan(callsBeforeAlphaReturn);
});
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options[0]).toHaveTextContent('Match-alpha');
});
});
test('keeps loading indicator while a newer request is in flight after a stale response is dropped', async () => {
// Regression for the P2 race: the `.finally` block that clears isLoading
// must not fire when a stale (dropped) response resolves while a newer
// request is still in flight. Otherwise the spinner disappears mid-search
// and the undebounced scroll-pagination handler can fire against stale
// totalCount before page 0 of the active search lands.
type OptionRow = { label: string; value: string | number };
type PageResponse = { data: OptionRow[]; totalCount: number };
// Initialized to no-op so the finally block can always call them, even if
// an assertion in the try throws before the corresponding mock ran.
let resolveAlpha: (value: PageResponse) => void = () => {};
let resolveBeta: (value: PageResponse) => void = () => {};
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
const loadOptions = jest.fn((search: string) => {
if (search === '') {
return Promise.resolve<PageResponse>({
data: page0Data,
totalCount: 100,
});
}
if (search === 'alpha') {
return new Promise<PageResponse>(resolve => {
resolveAlpha = resolve;
});
}
return new Promise<PageResponse>(resolve => {
resolveBeta = resolve;
});
});
const isSpinnerVisible = (): boolean =>
Boolean(document.querySelector('.ant-select-arrow .ant-spin'));
try {
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
// Type 'alpha' — alpha fetch is held, loading should be true.
await type('alpha');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10),
);
await waitFor(() => expect(isSpinnerVisible()).toBe(true));
// Type 'beta' — beta fetch is also held; both are in flight.
await type('beta');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('beta', 0, 10),
);
expect(isSpinnerVisible()).toBe(true);
// Release the stale alpha response. It is dropped at the early-return
// (search !== inputValueRef.current), but the in-flight counter is still
// non-zero because beta is pending — spinner must stay visible.
resolveAlpha({ data: alphaData, totalCount: 1 });
// Yield a microtask so alpha's .then/.finally runs, then re-assert.
await Promise.resolve();
expect(isSpinnerVisible()).toBe(true);
// Release beta. Now the in-flight counter drops to 0 and the spinner
// clears.
resolveBeta({ data: betaData, totalCount: 1 });
await waitFor(() => expect(isSpinnerVisible()).toBe(false));
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-beta');
} finally {
// Defensive: never leave a held promise that could hang a parallel worker
// if an assertion above threw. Promise resolve is idempotent.
resolveAlpha({ data: alphaData, totalCount: 1 });
resolveBeta({ data: betaData, totalCount: 1 });
}
});
test('re-shows search results when the same search term is repeated after a clear', async () => {
// Regression: a prior fix cached search responses' totalCount in
// fetchedQueries. After restore-on-clear had replaced selectOptions with
// the base list, re-typing a previously-resolved term would hit the cache
// short-circuit and leave selectOptions stale (empty / base-only).
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const alphaData = [{ label: 'Match-alpha', value: 'va' }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
// totalCount > data.length so allValuesLoaded stays false and the
// search path is not bypassed by the "all loaded" short-circuit.
return { data: page0Data, totalCount: 100 };
}
return { data: alphaData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
await type('alpha');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-alpha');
});
await type('');
await waitFor(() =>
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument(),
);
const callsBefore = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
await type('alpha');
await waitFor(() => {
const callsAfter = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
expect(callsAfter).toBeGreaterThan(callsBefore);
});
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-alpha');
});
});
test('appends page>1 results during an active search and discards them when search changes', async () => {
// Covers the production branch `else { mergeData(data) }` in fetchPage that
// fires when search is non-empty AND page > 0 — i.e. user scrolled within
// a multi-page search result. Switching to a new search must replace, not
// retain, the prior search's accumulated pages.
type OptionRow = { label: string; value: string | number };
const pageSize = 5;
const aliceData: OptionRow[] = Array.from({ length: 5 }, (_, i) => ({
label: `Alice-${i}`,
value: `a${i}`,
}));
const alicePage1: OptionRow[] = Array.from({ length: 3 }, (_, i) => ({
label: `Alice-${i + 5}`,
value: `a${i + 5}`,
}));
const bobData: OptionRow[] = [{ label: 'Bob-0', value: 'b0' }];
const loadOptions = jest.fn(
async (
search: string,
page: number,
): Promise<{
data: OptionRow[];
totalCount: number;
}> => {
if (search === '') {
return { data: [], totalCount: 100 };
}
if (search === 'alice') {
if (page === 0) return { data: aliceData, totalCount: 8 };
return { data: alicePage1, totalCount: 8 };
}
return { data: bobData, totalCount: 1 };
},
);
render(
<AsyncSelect {...defaultProps} pageSize={pageSize} options={loadOptions} />,
);
await open();
await type('alice');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('alice', 0, pageSize),
);
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(5);
});
// Wait for loading to finish so handlePagination's `!isLoading` gate is
// open before we fire scroll.
await waitFor(() =>
expect(document.querySelector('.ant-select-arrow .ant-spin')).toBeNull(),
);
// Trigger pagination by dispatching a scroll event on the virtual-list
// scroll container. jsdom returns 0 for layout properties by default, so
// override the relevant ones before firing scroll. rc-virtual-list reads
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
// then forwards to onPopupScroll (handlePagination here).
const holder = document.querySelector(
'.rc-virtual-list-holder',
) as HTMLElement | null;
if (!holder) throw new Error('virtual-list holder not rendered');
Object.defineProperty(holder, 'scrollHeight', {
configurable: true,
get: () => 1000,
});
Object.defineProperty(holder, 'offsetHeight', {
configurable: true,
get: () => 200,
});
Object.defineProperty(holder, 'clientHeight', {
configurable: true,
get: () => 200,
});
Object.defineProperty(holder, 'scrollTop', {
configurable: true,
get: () => 900,
set: () => {},
});
fireEvent.scroll(holder);
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('alice', 1, pageSize),
);
await waitFor(async () => {
const options = await findAllSelectOptions();
// Page 0 (5) + page 1 (3) merged
expect(options).toHaveLength(8);
});
// Switching to a new search must replace the accumulated pages, not retain
// them.
await type('bob');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('bob', 0, pageSize),
);
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Bob-0');
});
expect(screen.queryByText('Alice-0')).not.toBeInTheDocument();
expect(screen.queryByText('Alice-7')).not.toBeInTheDocument();
});
test('does not duplicate options when using numeric values', async () => {
render(
<AsyncSelect

View File

@@ -160,6 +160,13 @@ const AsyncSelect = forwardRef(
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
const selectValueRef = useRef(selectValue);
const fetchedQueries = useRef(new Map<string, number>());
const initialOptionsRef = useRef<SelectOptionsType>(EMPTY_OPTIONS);
const inputValueRef = useRef('');
// Counts fetches whose `.finally` has not yet run. Loading is cleared only
// when this drops to 0, so a stale response (which returns early without
// updating selectOptions) cannot flip the spinner off while a newer
// request is still pending.
const inFlightFetchesRef = useRef(0);
const mappedMode = isSingleMode ? undefined : 'multiple';
const allowFetch = !fetchOnlyOnSearch || inputValue;
const [maxTagCount, setMaxTagCount] = useState(
@@ -183,6 +190,10 @@ const AsyncSelect = forwardRef(
selectValueRef.current = selectValue;
}, [selectValue]);
useEffect(() => {
inputValueRef.current = inputValue;
}, [inputValue]);
const sortSelectedFirst = useCallback(
(a: AntdLabeledValue, b: AntdLabeledValue) =>
sortSelectedFirstHelper(a, b, selectValueRef.current),
@@ -333,22 +344,78 @@ const AsyncSelect = forwardRef(
setIsLoading(true);
const fetchOptions = options as SelectOptionsPagePromise;
inFlightFetchesRef.current += 1;
fetchOptions(search, page, pageSize)
.then(({ data, totalCount }: SelectOptionsTypePage) => {
const mergedData = mergeData(data);
fetchedQueries.current.set(key, totalCount);
setTotalCount(totalCount);
if (
!fetchOnlyOnSearch &&
search === '' &&
mergedData.length >= totalCount
) {
setAllValuesLoaded(true);
// Drop responses whose search arg no longer matches the user's
// current input — otherwise a slow base fetch can land after a
// search fetch (or a stale debounced search after a clear) and
// re-pollute the dropdown via mergeData / search-replace. Search
// responses are never cached in fetchedQueries: the cache stores
// only totalCount, so a cache hit would short-circuit the fetch
// and leave selectOptions stale (e.g. after restore-on-clear).
// Re-issuing the search is cheap and correct.
const matchesCurrentSearch = inputValueRef.current === search;
if (search && !matchesCurrentSearch) {
return;
}
if (!search) {
// Accumulate base pages in a ref independent of selectOptions
// (during an active search, selectOptions holds search results
// and is not a safe accumulator). The accumulator is kept up
// to date even when this response landed during a search, so
// restore-on-clear has a complete snapshot. We don't sort here
// — restore-on-clear sorts a copy at consumption time, and the
// live selectOptions path below goes through mergeData which
// sorts there. Sorting here too would double the per-page sort
// cost on large cached option sets.
const dataValues = new Set(data.map(opt => opt.value));
const accumulated = initialOptionsRef.current
.filter(opt => !dataValues.has(opt.value))
.concat(data);
initialOptionsRef.current = accumulated;
if (!fetchOnlyOnSearch && accumulated.length >= totalCount) {
setAllValuesLoaded(true);
}
fetchedQueries.current.set(key, totalCount);
if (matchesCurrentSearch) {
// No active search — push to live selectOptions and update
// totalCount. When matchesCurrentSearch is false, the user
// is mid-search; leave the search's totalCount in place so
// pagination math stays correct.
mergeData(data);
setTotalCount(totalCount);
}
} else if (page === 0) {
// Replace cached options with server results; preserve
// optimistic isNewOption entries inserted by handleOnSearch
// so allowNewOptions users can still click the value they
// typed when the server returns no match.
setSelectOptions(prevOptions => {
const dataValues = new Set(data.map(opt => opt.value));
const preservedNew = prevOptions.filter(
opt => opt.isNewOption && !dataValues.has(opt.value),
);
return preservedNew
.concat(data)
.sort(sortComparatorForNoSearch);
});
setTotalCount(totalCount);
} else {
// page > 0 during an active search — append normally.
mergeData(data);
setTotalCount(totalCount);
}
})
.catch(internalOnError)
.finally(() => {
setIsLoading(false);
inFlightFetchesRef.current = Math.max(
0,
inFlightFetchesRef.current - 1,
);
if (inFlightFetchesRef.current === 0) {
setIsLoading(false);
}
});
},
[
@@ -358,6 +425,7 @@ const AsyncSelect = forwardRef(
internalOnError,
options,
pageSize,
sortComparatorForNoSearch,
],
);
@@ -500,6 +568,7 @@ const AsyncSelect = forwardRef(
fetchedQueries.current.clear();
setAllValuesLoaded(false);
setSelectOptions(EMPTY_OPTIONS);
initialOptionsRef.current = EMPTY_OPTIONS;
}, [options]);
useEffect(() => {
@@ -514,16 +583,36 @@ const AsyncSelect = forwardRef(
[debouncedFetchPage],
);
const previousInputValue = usePrevious(inputValue, '');
useEffect(() => {
if (loadingEnabled && allowFetch) {
// trigger fetch every time inputValue changes
if (inputValue) {
debouncedFetchPage(inputValue, 0);
} else {
// Cancel any pending debounced search fetch so it can't fire after
// we've already restored the base list.
debouncedFetchPage.cancel();
// On returning to empty input after a search, restore the cached
// base options so the dropdown shows the original page-0 list
// instead of the stale search results.
if (previousInputValue && initialOptionsRef.current.length > 0) {
setSelectOptions(
[...initialOptionsRef.current].sort(sortComparatorForNoSearch),
);
}
fetchPage('', 0);
}
}
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
}, [
loadingEnabled,
fetchPage,
allowFetch,
inputValue,
previousInputValue,
debouncedFetchPage,
sortComparatorForNoSearch,
]);
useEffect(() => {
if (loading !== undefined && loading !== isLoading) {
@@ -531,7 +620,11 @@ const AsyncSelect = forwardRef(
}
}, [isLoading, loading]);
const clearCache = () => fetchedQueries.current.clear();
const clearCache = () => {
fetchedQueries.current.clear();
initialOptionsRef.current = EMPTY_OPTIONS;
setAllValuesLoaded(false);
};
useImperativeHandle(
ref,

View File

@@ -211,6 +211,10 @@ export const handleFilterOptionHelper = (
return filterOption(search, option);
}
if (filterOption === false) {
return true;
}
if (filterOption) {
const searchValue = search.trim().toLowerCase();
if (optionFilterProps?.length) {

View File

@@ -371,3 +371,37 @@ test('should handle large datasets with pagination', () => {
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
});
test('should reset to first page when data reduces below current page', async () => {
// Start with 30 items, 10 per page = 3 pages
const initialData = Array.from({ length: 30 }, (_, i) => ({
id: i,
age: 20 + i,
name: `Person ${i}`,
}));
const props = {
...mockedProps,
data: initialData,
pageSize: 10,
};
const { rerender } = render(<TableView {...props} />);
// Navigate to page 3 (last page)
const page3 = screen.getByRole('listitem', { name: '3' });
await userEvent.click(page3);
await waitFor(() => {
expect(screen.getByText('21-30 of 30')).toBeInTheDocument();
});
// Reduce data to only 5 items (fewer than current page would show)
const reducedData = initialData.slice(0, 5);
rerender(<TableView {...props} data={reducedData} />);
// Should reset to page 1 since page 3 no longer exists
await waitFor(() => {
expect(screen.getByText('1-5 of 5')).toBeInTheDocument();
});
});

View File

@@ -246,6 +246,21 @@ const RawTableView = ({
}
}, [initialSortBy, onServerPagination, serverPagination, sortBy]);
// Reset to first page when current page exceeds available pages
// (e.g., when filtering reduces the data below the current page)
const pageCount = Math.ceil(data.length / effectivePageSize);
useEffect(() => {
if (
withPagination &&
!serverPagination &&
!loading &&
pageIndex > pageCount - 1 &&
pageCount > 0
) {
setPageIndex(0);
}
}, [withPagination, serverPagination, loading, pageIndex, pageCount]);
return (
<TableViewStyles {...props} ref={tableRef}>
<TableCollection

View File

@@ -77,6 +77,7 @@ export interface ChartDataResponseResult {
// TODO(hainenber): define proper type for below attributes
rejected_filters?: any[];
applied_filters?: any[];
warning?: string | null;
/**
* Detected ISO 4217 currency code when AUTO mode is used.
* Returns the currency code if all filtered data contains a single currency,

View File

@@ -21,8 +21,8 @@ import { TreePathInfo } from '../types';
export const COLOR_SATURATION = [0.7, 0.4];
export const LABEL_FONTSIZE = 11;
export const BORDER_WIDTH = 2;
export const GAP_WIDTH = 2;
export const BORDER_WIDTH = 0;
export const GAP_WIDTH = 0;
export const extractTreePathInfo = (
treePathInfo: TreePathInfo[] | undefined,

View File

@@ -214,7 +214,8 @@ export default function transformProps(
colorAlpha: OpacityEnum.SemiTransparent,
color: theme.colorText,
borderColor: theme.colorBgBase,
borderWidth: 2,
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
},
label: {
...labelProps,

View File

@@ -18,6 +18,7 @@
*/
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import { OpacityEnum } from '../../src/constants';
import { EchartsTreemapChartProps } from '../../src/Treemap/types';
import transformProps from '../../src/Treemap/transformProps';
@@ -74,4 +75,44 @@ describe('Treemap transformProps', () => {
}),
);
});
test('should not render gaps between treemap nodes when filtered', () => {
const filteredChartProps = new ChartProps({
...chartProps,
filterState: { selectedValues: ['Sylvester,bar1'] },
});
expect(
transformProps(filteredChartProps as EchartsTreemapChartProps),
).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: [
expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
name: 'Arnold',
children: expect.arrayContaining([
expect.objectContaining({
name: 'bar2',
itemStyle: expect.objectContaining({
borderWidth: 0,
gapWidth: 0,
colorAlpha: OpacityEnum.SemiTransparent,
}),
label: expect.objectContaining({}),
}),
]),
}),
]),
}),
]),
}),
],
}),
}),
);
});
});

View File

@@ -29,7 +29,7 @@
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.24.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"react-map-gl": "^8.1.0",
"supercluster": "^8.0.1"
},
"peerDependencies": {

View File

@@ -27,7 +27,7 @@ jest.mock('../../DeckGLContainer', () => ({
}));
jest.mock('../../factory', () => ({
createDeckGLComponent: jest.fn(() => () => null),
createCategoricalDeckGLComponent: jest.fn(() => () => null),
GetLayerType: {},
}));
@@ -53,6 +53,14 @@ const mockPayload = {
},
};
const mockLayerParams = {
onContextMenu: jest.fn(),
filterState: undefined,
setDataMask: jest.fn(),
setTooltip: jest.fn(),
emitCrossFilters: false,
};
test('getLayer uses line_width_unit from formData', () => {
const layer = getLayer({
formData: mockFormData,
@@ -117,3 +125,518 @@ test('getPoints extracts points from path data', () => {
expect(points[0]).toEqual([0, 0]);
expect(points[2]).toEqual([2, 2]);
});
test('Fixed width mode returns constant width for all paths', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 5,
},
{
path: [
[2, 2],
[3, 3],
],
width: 5,
},
{
path: [
[4, 4],
[5, 5],
],
width: 5,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
min_width: 1,
max_width: 20,
line_width_multiplier: 1,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
const widths = data.map(d => d.width);
widths.forEach(width => {
expect(width).toBe(widths[0]);
});
});
test('Fixed width mode applies multiplier correctly', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 5,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
line_width_multiplier: 3,
min_width: 1,
max_width: 100,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].width).toBe(15);
});
test('Fixed width mode enforces minimum width bound', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 0.1,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
min_width: 2,
max_width: 20,
line_width_multiplier: 1,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].width).toBeGreaterThanOrEqual(2);
});
test('Fixed width mode enforces maximum width bound', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 100,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
min_width: 1,
max_width: 20,
line_width_multiplier: 1,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].width).toBeLessThanOrEqual(20);
});
test('Fixed width mode defaults width to 1 when no width is provided', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
line_width: undefined,
min_width: 1,
max_width: 20,
line_width_multiplier: 1,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].width).toBe(1);
});
test('Metric mode normalizes widths proportionally between min and max bounds', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 100,
},
{
path: [
[2, 2],
[3, 3],
],
width: 200,
},
{
path: [
[4, 4],
[5, 5],
],
width: 300,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
line_width: { type: 'metric', value: 'some_metric' },
min_width: 1,
max_width: 20,
line_width_multiplier: 1,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
const widths = data.map((d: any) => d.width);
expect(widths[0]).toBeCloseTo(1);
expect(widths[1]).toBeCloseTo(10.5);
expect(widths[2]).toBeCloseTo(20);
});
test('Metric mode applies multiplier after normalization', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 100,
},
{
path: [
[2, 2],
[3, 3],
],
width: 200,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
line_width: { type: 'metric', value: 'some_metric' },
min_width: 1,
max_width: 20,
line_width_multiplier: 2,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].width).toBeCloseTo(2);
expect(data[1].width).toBe(20);
});
test('Metric mode enforces bounds after multiplier', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 100,
},
{
path: [
[2, 2],
[3, 3],
],
width: 500,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
min_width: 5,
max_width: 15,
line_width_multiplier: 10,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
data.forEach((d: any) => {
expect(d.width).toBeGreaterThanOrEqual(5);
expect(d.width).toBeLessThanOrEqual(15);
});
});
test('Metric mode handles equal width values.', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 100,
},
{
path: [
[2, 2],
[3, 3],
],
width: 100,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
min_width: 1,
max_width: 20,
line_width_multiplier: 1,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].width).toBe(data[1].width);
});
test('Metric mode handles null width values', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
width: 100,
},
{
path: [
[2, 2],
[3, 3],
],
width: null,
},
{
path: [
[4, 4],
[5, 5],
],
width: 300,
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
line_width: { type: 'metric', value: 'some_metric' },
min_width: 1,
max_width: 20,
line_width_multiplier: 1,
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[1].width).toBe(1);
expect(data[0].width).toBeCloseTo(1);
expect(data[2].width).toBeCloseTo(20);
});
test('Fixed color mode returns same color for all paths', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
},
{
path: [
[2, 2],
[3, 3],
],
},
{
path: [
[4, 4],
[5, 5],
],
},
],
},
};
const layer = getLayer({
formData: {
...mockFormData,
color_picker: { r: 255, g: 100, b: 50, a: 1 },
},
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
const expectedColor = [255, 100, 50, 255];
data.forEach((d: any) => {
expect(d.color).toEqual(expectedColor);
});
});
test('Categorical mode preserves distinct colors for selected categories', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
color: [255, 0, 0, 255],
cat_color: 'A',
},
{
path: [
[2, 2],
[3, 3],
],
color: [0, 0, 255, 255],
cat_color: 'B',
},
{
path: [
[4, 4],
[5, 5],
],
color: [255, 0, 0, 255],
cat_color: 'A',
},
],
},
};
const layer = getLayer({
formData: mockFormData,
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].color).toEqual(data[2].color);
expect(data[0].color).not.toEqual(data[1].color);
});
test('Breakpoint mode preserves colors assigned by addColor based on metric ranges', () => {
const payload = {
data: {
features: [
{
path: [
[0, 0],
[1, 1],
],
color: [255, 0, 0, 255],
metric: 50,
},
{
path: [
[2, 2],
[3, 3],
],
color: [0, 0, 255, 255],
metric: 200,
},
{
path: [
[4, 4],
[5, 5],
],
color: [255, 0, 0, 255],
metric: 75,
},
],
},
};
const layer = getLayer({
formData: mockFormData,
payload,
...mockLayerParams,
});
const data = layer.props.data as any[];
expect(data[0].color).toEqual(data[2].color);
expect(data[0].color).not.toEqual(data[1].color);
});

View File

@@ -21,13 +21,14 @@ import { PathLayer } from '@deck.gl/layers';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
import { Point } from '../../types';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
import { isMetricValue } from '../utils/metricUtils';
function setTooltipContent(formData: QueryFormData) {
const defaultTooltipGenerator = (o: JsonObject) => (
@@ -50,14 +51,69 @@ export const getLayer: GetLayerType<PathLayer> = function ({
emitCrossFilters,
}) {
const fd = formData;
const c = fd.color_picker;
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
let data = payload.data.features.map((feature: JsonObject) => ({
...feature,
path: feature.path,
width: fd.line_width,
color: fixedColor,
}));
let data = payload.data.features.map((feature: JsonObject) => {
if (feature.color) {
return { ...feature };
}
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const color = [c.r, c.g, c.b, 255 * c.a];
return {
...feature,
path: feature.path,
color,
};
});
// Variables for width scaling and normalization
const minWidth = Number(fd.min_width) || 1; // defaulted to 1
const maxWidth = Number(fd.max_width) || 20; // defaulted to 20
const multiplier = Number(fd.line_width_multiplier) || 1; // defaulted to 1
const widths = data.map((d: JsonObject) => d.width).filter(Number.isFinite);
// Metric or fixed value
const isMetricWidth = isMetricValue(fd.line_width);
if (isMetricWidth) {
// Get minimum and maximum widths in data set
const minVal = widths.length > 0 ? Math.min(...widths) : minWidth;
const maxVal = widths.length > 0 ? Math.max(...widths) : maxWidth;
data = data.map((d: JsonObject) => {
if (d.width == null) return { ...d, width: minWidth };
const normalized =
maxVal === minVal ? 0.5 : (d.width - minVal) / (maxVal - minVal);
// Map within range of min + max
let width = minWidth + normalized * (maxWidth - minWidth);
// Apply scaling multiplier
width *= multiplier;
// Enforce minimum and maximum width bounds
width = Math.max(minWidth, Math.min(maxWidth, width));
return { ...d, width };
});
} else {
// Fixed width mode
// Allows for use with legacy charts
const fixedWidth =
typeof fd.line_width === 'number'
? fd.line_width
: typeof fd.line_width === 'object' && fd.line_width?.type === 'fix'
? Number(fd.line_width.value)
: undefined;
data = data.map((d: JsonObject) => {
let width = (d.width ?? fixedWidth ?? 1) * multiplier;
width = Math.max(minWidth, Math.min(maxWidth, width));
return { ...d, width };
});
}
if (fd.js_data_mutator) {
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
@@ -66,13 +122,15 @@ export const getLayer: GetLayerType<PathLayer> = function ({
return new PathLayer({
id: `path-layer-${fd.slice_id}` as const,
getColor: (d: any) => d.color,
getColor: (d: any) => d.color || [0, 0, 0, 255],
getPath: (d: any) => d.path,
getWidth: (d: any) => d.width,
data,
rounded: true,
widthScale: 1,
widthUnits: fd.line_width_unit,
widthMinPixels: Number(fd.min_width) || undefined,
widthMaxPixels: Number(fd.max_width) || undefined,
...commonLayerProps({
formData: fd,
setTooltip,
@@ -101,13 +159,23 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
filterState,
}) {
const fd = formData;
const minWidth = Number(fd.min_width) || 1;
const maxWidth = Number(fd.max_width) || 20;
const multiplier = Number(fd.line_width_multiplier) || 1;
const fixedColor = HIGHLIGHT_COLOR_ARRAY;
let data = payload.data.features.map((feature: JsonObject) => ({
...feature,
path: feature.path,
width: fd.line_width,
color: fixedColor,
}));
let data = payload.data.features.map((feature: JsonObject) => {
const baseWidth = Number.isFinite(feature.width) ? feature.width : 1;
let width = baseWidth * multiplier;
width = Math.max(minWidth, Math.min(maxWidth, width));
return {
...feature,
path: feature.path,
width,
color: fixedColor,
};
});
if (fd.js_data_mutator) {
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
@@ -128,7 +196,13 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
rounded: true,
widthScale: 1,
widthUnits: fd.line_width_unit,
widthMinPixels: Number(fd.min_width) || undefined,
widthMaxPixels: Number(fd.max_width) || undefined,
});
};
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
export default createCategoricalDeckGLComponent(
getLayer,
getPoints,
getHighlightLayer,
);

View File

@@ -0,0 +1,355 @@
/**
* 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 buildQuery, { DeckPathFormData } from './buildQuery';
const baseFormData: DeckPathFormData = {
datasource: '1__table',
viz_type: 'deck_path',
line_column: 'path_json',
line_type: 'json',
row_limit: 100,
};
test('Path buildQuery should not include metric when line_width is fixed type', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'fix',
value: 5,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual([]);
});
test('Path buildQuery should handle numeric line_width value with fixed type', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'fix',
value: 5,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual([]);
});
test('Path buildQuery should handle missing line_width', () => {
const formData: DeckPathFormData = {
...baseFormData,
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual([]);
});
test('Path buildQuery should include metric when line_width is metric type', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: 'COUNT(*)',
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContain('COUNT(*)');
});
test('Path buildQuery should add line_column to groupby when using width metric', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: 'SUM(distance)',
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.groupby).toContain('path_json');
});
test('Path buildQuery should handle adhoc SQL metric for line_width', () => {
const adhocMetric = {
label: 'custom_width',
expressionType: 'SQL' as const,
sqlExpression: 'SUM(weight) / COUNT(*)',
};
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: adhocMetric,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContainEqual(adhocMetric);
});
test('Path buildQuery should handle adhoc SIMPLE metric for line_width', () => {
const adhocMetric = {
label: 'AVG(traffic)',
expressionType: 'SIMPLE' as const,
column: { column_name: 'traffic' },
aggregate: 'AVG' as const,
};
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: adhocMetric,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContainEqual(adhocMetric);
});
test('Path buildQuery should handle metric type with undefined value', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: undefined,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual([]);
});
test('Path buildQuery should not duplicate width metric if already in metrics', () => {
const formData: DeckPathFormData = {
...baseFormData,
metrics: ['AVG(weight)'],
line_width: {
type: 'metric',
value: 'AVG(weight)',
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toHaveLength(1);
});
test('Path buildQuery should preserve existing metrics when adding width metric', () => {
const formData: DeckPathFormData = {
...baseFormData,
metrics: ['COUNT(*)'],
line_width: {
type: 'metric',
value: 'AVG(weight)',
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContain('COUNT(*)');
expect(query.metrics).toContain('AVG(weight)');
expect(query.metrics).toHaveLength(2);
});
test('Path buildQuery should not modify existing metrics for fixed width', () => {
const formData: DeckPathFormData = {
...baseFormData,
metrics: ['COUNT(*)', 'SUM(value)'],
line_width: {
type: 'fix',
value: 5,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual(['COUNT(*)', 'SUM(value)']);
});
test('Path buildQuery should handle undefined value in metric type gracefully', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: undefined,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
// Should not add anything when value is undefined
expect(query.metrics).toEqual([]);
});
test('Path buildQuery should handle line_width with undefined type', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: undefined,
value: 2,
},
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual([]);
});
// ─── Dimension (categorical color) ───
test('Path buildQuery should include dimension column when specified', () => {
const formData: DeckPathFormData = {
...baseFormData,
dimension: 'route_type',
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.columns).toContain('route_type');
});
test('Path buildQuery should include breakpoint_metric when specified', () => {
const formData: DeckPathFormData = {
...baseFormData,
breakpoint_metric: 'AVG(speed)',
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContain('AVG(speed)');
});
test('Path buildQuery should add line_column to groupby when using breakpoint metric', () => {
const formData: DeckPathFormData = {
...baseFormData,
breakpoint_metric: 'AVG(speed)',
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.groupby).toContain('path_json');
});
test('Path buildQuery should not duplicate breakpoint metric if already in metrics', () => {
const formData: DeckPathFormData = {
...baseFormData,
metrics: ['AVG(speed)'],
breakpoint_metric: 'AVG(speed)',
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toHaveLength(1);
expect(query.metrics).toContain('AVG(speed)');
});
test('Path buildQuery should handle breakpoint_metric and line_width metric together', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: 'SUM(distance)',
},
breakpoint_metric: 'AVG(speed)',
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContain('SUM(distance)');
expect(query.metrics).toContain('AVG(speed)');
});
test('Path buildQuery should handle adhoc breakpoint metric', () => {
const adhocMetric = {
label: 'avg_speed',
expressionType: 'SQL' as const,
sqlExpression: 'AVG(speed_mph)',
};
const formData: DeckPathFormData = {
...baseFormData,
breakpoint_metric: adhocMetric,
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContainEqual(adhocMetric);
});
test('Path buildQuery should handle missing breakpoint_metric', () => {
const formData: DeckPathFormData = {
...baseFormData,
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual([]);
});
test('Path buildQuery should handle line_width and breakpoint_metrics together together', () => {
const formData: DeckPathFormData = {
...baseFormData,
line_width: {
type: 'metric',
value: 'SUM(distance)',
},
breakpoint_metric: 'AVG(speed)',
js_columns: ['color'],
tooltip_contents: ['name'],
row_limit: 500,
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toContain('SUM(distance)');
expect(query.metrics).toContain('AVG(speed)');
expect(query.columns).toContain('color');
expect(query.columns).toContain('name');
expect(query.row_limit).toBe(500);
});

View File

@@ -19,10 +19,13 @@
import {
buildQueryContext,
ensureIsArray,
getMetricLabel,
SqlaFormData,
QueryFormColumn,
QueryFormMetric,
} from '@superset-ui/core';
import { addNullFilters, addTooltipColumnsToQuery } from '../buildQueryUtils';
import { isMetricValue } from '../utils/metricUtils';
export interface DeckPathFormData extends SqlaFormData {
line_column?: string;
@@ -32,10 +35,26 @@ export interface DeckPathFormData extends SqlaFormData {
js_columns?: string[];
tooltip_contents?: unknown[];
tooltip_template?: string;
line_width?:
| string
| { type?: 'fix' | 'metric'; value?: QueryFormMetric | number };
line_width_multiplier?: number;
min_width?: number;
max_width?: number;
dimension?: string;
breakpoint_metric?: QueryFormMetric;
}
export default function buildQuery(formData: DeckPathFormData) {
const { line_column, metric, js_columns, tooltip_contents } = formData;
const {
line_column,
metric,
js_columns,
tooltip_contents,
line_width,
dimension,
breakpoint_metric,
} = formData;
if (!line_column) {
throw new Error('Line column is required for Path charts');
@@ -46,7 +65,7 @@ export default function buildQuery(formData: DeckPathFormData) {
const columns = ensureIsArray(
baseQueryObject.columns || [],
) as QueryFormColumn[];
const metrics = ensureIsArray(baseQueryObject.metrics || []);
let metrics = ensureIsArray(baseQueryObject.metrics || []);
const groupby = ensureIsArray(
baseQueryObject.groupby || [],
) as QueryFormColumn[];
@@ -63,6 +82,49 @@ export default function buildQuery(formData: DeckPathFormData) {
columns.push(line_column);
}
// Include dimension column for categorical color mode
if (dimension && !columns.includes(dimension)) {
columns.push(dimension);
}
// Add metric if line_width is a metric type
const isMetric = isMetricValue(line_width);
const rawWidthValue =
typeof line_width === 'string'
? line_width
: typeof line_width === 'number'
? undefined
: line_width?.value;
const widthMetric: QueryFormMetric | null =
isMetric &&
rawWidthValue !== undefined &&
typeof rawWidthValue !== 'number'
? (rawWidthValue as QueryFormMetric)
: null;
// ensure metric is not added to metric array twice
const existingLabels = new Set(metrics.map(m => getMetricLabel(m)));
if (widthMetric && !existingLabels.has(getMetricLabel(widthMetric))) {
metrics = [...metrics, widthMetric];
}
// ensure line_column is in groupby when aggregating by width metric
if (widthMetric && !groupby.includes(line_column)) {
groupby.push(line_column);
}
if (breakpoint_metric) {
const breakpointLabel = getMetricLabel(breakpoint_metric);
const currentLabels = new Set(metrics.map(m => getMetricLabel(m)));
if (!currentLabels.has(breakpointLabel)) {
metrics = [...metrics, breakpoint_metric];
}
// ensure line_column is in groupby when aggregating
if (!groupby.includes(line_column)) {
groupby.push(line_column);
}
}
jsColumns.forEach(col => {
if (!columns.includes(col) && !groupby.includes(col)) {
columns.push(col);

View File

@@ -0,0 +1,242 @@
/**
* 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 {
ControlPanelSectionConfig,
ControlSetRow,
ControlSetItem,
} from '@superset-ui/chart-controls';
import controlPanel from './controlPanel';
test('controlPanel should have Path Size section', () => {
const pathSizeSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Size',
);
expect(pathSizeSection).toBeDefined();
expect(pathSizeSection?.expanded).toBe(true);
});
test('controlPanel should include pathLineWidthFixedOrMetric control', () => {
const pathSizeSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Size',
);
const control = pathSizeSection?.controlSetRows
.flat()
.find(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'line_width',
) as any;
expect(control).toBeDefined();
expect(control.config.type).toBe('FixedOrMetricControl');
expect(control.config.default).toEqual({ type: 'fix', value: 1 });
});
test('controlPanel should include line_width_unit control with pixels as default', () => {
const pathSizeSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Size',
);
const lineWidthRow = pathSizeSection?.controlSetRows.find(
(row: ControlSetRow) =>
row.some(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'line_width_unit',
),
);
const lineWidthControl = lineWidthRow?.find(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'line_width_unit',
) as any;
expect(lineWidthControl).toBeDefined();
expect(lineWidthControl?.config?.default).toBe('pixels');
});
test('controlPanel should include min_width control with default of 1', () => {
const minWidthSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Size',
);
const minWidthRow = minWidthSection?.controlSetRows.find(
(row: ControlSetRow) =>
row.some(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'min_width',
),
);
const minWidthControl = minWidthRow?.find(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'min_width',
) as any;
expect(minWidthControl).toBeDefined();
expect(minWidthControl?.config?.default).toBe(1);
});
test('controlPanel should include max_width control with default of 20', () => {
const maxWidthSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Size',
);
const maxWidthRow = maxWidthSection?.controlSetRows.find(
(row: ControlSetRow) =>
row.some(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'max_width',
),
);
const maxWidthControl = maxWidthRow?.find(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'max_width',
) as any;
expect(maxWidthControl).toBeDefined();
expect(maxWidthControl?.config?.default).toBe(20);
});
test('controlPanel should include line_width_multiplier control with default of 1', () => {
const lineWidthMultiplierSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Size',
);
const lineWidthMultiplierRow =
lineWidthMultiplierSection?.controlSetRows.find((row: ControlSetRow) =>
row.some(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'line_width_multiplier',
),
);
const lineWidthMultiplierControl = lineWidthMultiplierRow?.find(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'line_width_multiplier',
) as any;
expect(lineWidthMultiplierControl).toBeDefined();
expect(lineWidthMultiplierControl?.config?.default).toBe(1);
});
test('controlPanel should have Path Color section', () => {
const pathColorSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Color',
);
expect(pathColorSection).toBeDefined();
expect(pathColorSection?.expanded).toBe(true);
});
test('controlPanel should have Path Color section with color scheme controls', () => {
const pathColorSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Color',
);
const controlNames = pathColorSection?.controlSetRows
.flat()
.filter(
(control: ControlSetItem) =>
control && typeof control === 'object' && 'name' in control,
)
.map((control: any) => control.name);
expect(controlNames).toContain('color_scheme_type');
expect(controlNames).toContain('color_picker');
expect(controlNames).toContain('dimension');
expect(controlNames).toContain('color_scheme');
expect(controlNames).toContain('breakpoint_metric');
expect(controlNames).toContain('default_breakpoint_color');
expect(controlNames).toContain('color_breakpoints');
});
test('color_scheme_type should default to fixed_color', () => {
const pathColorSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section != null && section.label === 'Path Color',
);
const schemeTypeControl = pathColorSection?.controlSetRows
.flat()
.find(
(control: ControlSetItem) =>
control &&
typeof control === 'object' &&
'name' in control &&
control.name === 'color_scheme_type',
) as any;
expect(schemeTypeControl).toBeDefined();
expect(schemeTypeControl?.config?.default).toBe('fixed_color');
});

View File

@@ -26,7 +26,6 @@ import {
jsTooltip,
jsOnclickHref,
viewport,
lineWidth,
lineType,
reverseLongLat,
mapboxStyle,
@@ -34,8 +33,12 @@ import {
mapProvider,
tooltipContents,
tooltipTemplate,
pathLineWidthFixedOrMetric,
generateDeckGLColorSchemeControls,
} from '../../utilities/Shared_DeckGL';
import { dndLineColumn } from '../../utilities/sharedDndControls';
import { validateNonEmpty } from '@superset-ui/core';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -71,25 +74,83 @@ const config: ControlPanelConfig = {
[mapboxStyle],
[maplibreStyle],
[viewport],
['color_picker'],
[lineWidth],
[reverseLongLat],
[autozoom],
],
},
{
label: t('Path Size'),
expanded: true,
controlSetRows: [
[pathLineWidthFixedOrMetric],
[
{
name: 'line_width_unit',
config: {
type: 'SelectControl',
label: t('Line width unit'),
default: 'meters',
default: 'pixels',
choices: [
['meters', t('meters')],
['pixels', t('pixels')],
],
renderTrigger: true,
},
},
],
[reverseLongLat],
[autozoom],
[
{
name: 'min_width',
config: {
type: 'TextControl',
label: t('Minimum Width'),
isFloat: true,
validators: [validateNonEmpty],
renderTrigger: true,
default: 1,
description: t(
'Minimum width size of the path, in pixels or meters.',
),
},
},
{
name: 'max_width',
config: {
type: 'TextControl',
label: t('Maximum Width'),
isFloat: true,
validators: [validateNonEmpty],
renderTrigger: true,
default: 20,
description: t(
'Maximum width size of the path, in pixels or meters.',
),
},
},
],
[
{
name: 'line_width_multiplier',
config: {
type: 'TextControl',
label: t('Width scale multiplier'),
renderTrigger: true,
isFloat: true,
default: 1,
description: t(
'Scale factor applied to metric-driven line widths',
),
},
},
],
],
},
{
label: t('Path Color'),
expanded: true,
controlSetRows: [
...generateDeckGLColorSchemeControls({
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
}),
],
},
{

View File

@@ -0,0 +1,364 @@
/**
* 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 { ChartProps, DatasourceType } from '@superset-ui/core';
import transformProps from './transformProps';
interface PathFeature {
path: [number, number][];
width?: number;
metric?: number;
cat_color?: string;
extraProps?: Record<string, unknown>;
[key: string]: unknown;
}
const samplePath1 = JSON.stringify([
[-122.4, 37.8],
[-122.3, 37.9],
]);
const samplePath2 = JSON.stringify([
[-122.5, 37.7],
[-122.4, 37.8],
]);
const samplePath3 = JSON.stringify([
[-122.6, 37.6],
[-122.5, 37.7],
]);
const mockChartProps: Partial<ChartProps> = {
rawFormData: {
line_column: 'path_json',
line_type: 'json',
viewport: {},
},
queriesData: [
{
data: [
{
path_json: samplePath1,
'AVG(weight)': 100,
'SUM(distance)': 500,
route_type: 'express',
},
{
path_json: samplePath2,
'AVG(weight)': 200,
'SUM(distance)': 1000,
route_type: 'local',
},
{
path_json: samplePath3,
'AVG(weight)': 50,
'SUM(distance)': 250,
route_type: 'express',
},
],
},
],
datasource: {
type: DatasourceType.Table,
id: 1,
name: 'test_datasource',
columns: [],
metrics: [],
},
height: 400,
width: 600,
hooks: {},
filterState: {},
emitCrossFilters: false,
};
test('Path transformProps should parse JSON paths correctly', () => {
const result = transformProps(mockChartProps as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features.length).toBe(3);
features.forEach(f => {
expect(f.path).toBeDefined();
expect(Array.isArray(f.path)).toBe(true);
expect(f.path.length).toBeGreaterThan(0);
});
});
test('Path transformProps should handle empty records', () => {
const props = {
...mockChartProps,
queriesData: [{ data: [] }],
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features).toHaveLength(0);
});
test('Path transformProps should handle missing line_column', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
line_column: undefined,
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features).toHaveLength(0);
});
test('Path transformProps should handle invalid JSON path data', () => {
const props = {
...mockChartProps,
queriesData: [
{
data: [{ path_json: 'not valid json' }, { path_json: '12345' }],
},
],
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features.length).toBe(2);
// Should not throw, paths should be empty arrays
features.forEach(f => {
expect(Array.isArray(f.path)).toBe(true);
});
});
test('Path transformProps should use fixed width value when line_width type is "fix"', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
line_width: {
type: 'fix',
value: 5,
},
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features.length).toBe(3);
features.forEach(f => {
expect(f.width).toBe(5);
});
});
test('Path transformProps should use fixed width with string value', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
line_width: {
type: 'fix',
value: '10',
},
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
features.forEach(f => {
expect(f.width).toBe(10);
});
});
test('Path transformProps should not set width when line_width is missing', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
line_width: undefined,
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
features.forEach(f => {
expect(f.width).toBeUndefined();
});
});
test('Path transformProps should use metric value for width when line_width type is "metric"', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
line_width: {
type: 'metric',
value: 'AVG(weight)',
},
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features).toHaveLength(3);
expect(features[0]?.width).toBe(50);
});
test('Path transformProps should include metric from breakpoint_metric', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
breakpoint_metric: 'AVG(weight)',
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
const metrics = features
.map(f => f.metric)
.filter((m): m is number => m !== undefined)
.sort((a, b) => a - b);
expect(metrics).toEqual([50, 100, 200]);
});
test('Path transformProps should fall back to base metric when breakpoint_metric is missing', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
metric: 'AVG(weight)',
breakpoint_metric: undefined,
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
const metrics = features
.map(f => f.metric)
.filter((m): m is number => m !== undefined)
.sort((a, b) => a - b);
expect(metrics).toEqual([50, 100, 200]);
});
test('Path transformProps should include both breakpoint_metric and width metrics if they are different', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
breakpoint_metric: 'AVG(weight)',
line_width: {
type: 'metric',
value: 'SUM(distance)',
},
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features).toHaveLength(3);
expect(result.payload.data.metricLabels).toEqual([
'AVG(weight)',
'SUM(distance)',
]);
});
test('Path transformProps should not include both breakpoint_metric and width metrics if they are the same', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
breakpoint_metric: 'SUM(distance)',
line_width: {
type: 'metric',
value: 'SUM(distance)',
},
},
};
const result = transformProps(props as ChartProps);
expect(result.payload.data.metricLabels).toEqual(['SUM(distance)']);
});
test('Path transformProps should set cat_color from dimension column', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
dimension: 'route_type',
},
};
const result = transformProps(props as ChartProps);
const features = result.payload.data.features as PathFeature[];
expect(features).toHaveLength(3);
expect(features[0]?.cat_color).toBe('express');
expect(features[1]?.cat_color).toBe('local');
expect(features[2]?.cat_color).toBe('express');
});
test('Path transformProps should include metric labels when breakpoint_metric is set', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
breakpoint_metric: 'AVG(weight)',
},
};
const result = transformProps(props as ChartProps);
expect(result.payload.data.metricLabels).toContain('AVG(weight)');
});
test('Path transformProps should include metric labels from base metric', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
metric: 'SUM(distance)',
},
};
const result = transformProps(props as ChartProps);
expect(result.payload.data.metricLabels).toContain('SUM(distance)');
});
test('Path transformProps should have empty metric labels when no metric is set', () => {
const props = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
metric: undefined,
breakpoint_metric: undefined,
},
};
const result = transformProps(props as ChartProps);
expect(result.payload.data.metricLabels).toEqual([]);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, DTTM_ALIAS } from '@superset-ui/core';
import { ChartProps, DTTM_ALIAS, getMetricLabel } from '@superset-ui/core';
import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils';
import {
createBaseTransformResult,
@@ -26,6 +26,7 @@ import {
addPropertiesToFeature,
} from '../transformUtils';
import { DeckPathFormData } from './buildQuery';
import { isFixedValue, getFixedValue } from '../utils/metricUtils';
declare global {
interface Window {
@@ -48,6 +49,8 @@ interface PathFeature {
path: [number, number][];
metric?: number;
timestamp?: unknown;
width?: number;
cat_color?: string;
extraProps?: Record<string, unknown>;
[key: string]: unknown;
}
@@ -91,6 +94,9 @@ function processPathData(
reverseLongLat: boolean = false,
metricLabel?: string,
jsColumns?: string[],
widthMetricLabel?: string,
fixedWidthValue?: number | string | null,
categoryColumn?: string,
): PathFeature[] {
if (!records.length || !lineColumn) {
return [];
@@ -103,6 +109,8 @@ function processPathData(
'timestamp',
DTTM_ALIAS,
metricLabel,
widthMetricLabel,
categoryColumn,
...(jsColumns || []),
].filter(Boolean) as string[],
);
@@ -130,6 +138,24 @@ function processPathData(
feature.metric = metricValue;
}
}
// Set width from metric or fixed value
if (fixedWidthValue != null) {
// Use fixed width
const parsedFixedWidth = parseMetricValue(fixedWidthValue);
if (parsedFixedWidth !== undefined) {
feature.width = parsedFixedWidth;
}
} else if (widthMetricLabel && record[widthMetricLabel] != null) {
// Use metric value for width
const widthValue = parseMetricValue(record[widthMetricLabel]);
if (widthValue !== undefined) {
feature.width = widthValue;
}
}
if (categoryColumn && record[categoryColumn] != null) {
feature.cat_color = String(record[categoryColumn]);
}
feature = addJsColumnsToExtraProps(feature, record, jsColumns);
feature = addPropertiesToFeature(feature, record, excludeKeys);
@@ -143,11 +169,37 @@ export default function transformProps(chartProps: ChartProps) {
line_column,
line_type = 'json',
metric,
line_width,
dimension,
reverse_long_lat = false,
js_columns,
breakpoint_metric,
} = formData as DeckPathTransformPropsFormData;
const metricLabel = getMetricLabelFromFormData(metric);
// Check so legacy values still work
const fixedWidthValue =
typeof line_width === 'number'
? line_width
: isFixedValue(line_width)
? getFixedValue(line_width)
: undefined;
const widthMetricLabel = getMetricLabelFromFormData(line_width);
const breakpointMetricLabel = breakpoint_metric
? getMetricLabel(breakpoint_metric)
: undefined;
const baseMetricLabel = getMetricLabelFromFormData(metric);
const metricLabel = breakpointMetricLabel || baseMetricLabel;
// ensure all metric labels are included
const metricLabels = [
...(metricLabel ? [metricLabel] : []),
...(widthMetricLabel && widthMetricLabel !== metricLabel
? [widthMetricLabel]
: []),
];
const records = getRecordsFromQuery(chartProps.queriesData);
const features = processPathData(
records,
@@ -156,11 +208,10 @@ export default function transformProps(chartProps: ChartProps) {
reverse_long_lat,
metricLabel,
js_columns,
widthMetricLabel,
fixedWidthValue,
dimension,
).reverse();
return createBaseTransformResult(
chartProps,
features,
metricLabel ? [metricLabel] : [],
);
return createBaseTransformResult(chartProps, features, metricLabels);
}

View File

@@ -285,6 +285,22 @@ export const lineWidth = {
},
};
// created new const so as not to break lineWidth usages in other charts
export const pathLineWidthFixedOrMetric = {
name: 'line_width',
config: {
type: 'FixedOrMetricControl', // using existing type
label: t('Line width'),
default: { type: 'fix', value: 1 }, // kept same default as before
description: t(
'The width of the lines as either a fixed value or variable width based on a metric.',
),
mapStateToProps: (state: ControlPanelState) => ({
datasource: state.datasource,
}),
},
};
export const fillColorPicker: CustomControlItem = {
name: 'fill_color_picker',
config: {
@@ -673,6 +689,24 @@ export const deckGLColorBreakpointsSelect: CustomControlItem = {
},
};
export const deckGLBreakpointMetric: CustomControlItem = {
name: 'breakpoint_metric',
config: {
...sharedControls.metric,
label: t('Breakpoint Metric'),
default: null,
validators: [],
description: t(
'Select the metric used to determine which color breakpoint range each path falls into.',
),
// mapStateToProps: (state: ControlPanelState) => ({
// datasource: state.datasource,
// }),
visibility: ({ controls }: { controls: any }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
},
};
export const breakpointsDefaultColor: CustomControlItem = {
name: 'default_breakpoint_color',
config: {
@@ -725,6 +759,7 @@ export const generateDeckGLColorSchemeControls = ({
[deckGLFixedColor],
disableCategoricalColumn ? [] : [deckGLCategoricalColor],
[deckGLCategoricalColorSchemeSelect],
[deckGLBreakpointMetric],
[breakpointsDefaultColor],
[deckGLColorBreakpointsSelect],
];

View File

@@ -47,6 +47,13 @@ const Title = styled.h4`
font-weight: ${({ theme }) => theme.fontWeightStrong};
`;
const StyledTabs = styled(Tabs)`
margin-top: ${({ theme }) => theme.sizeUnit * -8}px;
.ant-tabs-nav {
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
}
`;
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
const ssql = sql || '';
let lines = ssql.split('\n');
@@ -94,7 +101,7 @@ function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
}
return (
<Tabs
<StyledTabs
defaultActiveKey="executed"
items={[
{

View File

@@ -0,0 +1,53 @@
/**
* 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 config from './controlPanel';
type ControlConfig = {
label?: unknown;
description?: unknown;
};
type ControlItem = {
config: ControlConfig;
} | null;
function collectFunctionProps(cfg: typeof config) {
const fns: Array<() => unknown> = [];
cfg.controlPanelSections.forEach(section => {
section?.controlSetRows.forEach(row => {
(row as ControlItem[]).forEach(item => {
if (item && typeof item === 'object' && 'config' in item) {
const { label, description } = item.config;
if (typeof label === 'function') fns.push(label as () => unknown);
if (typeof description === 'function')
fns.push(description as () => unknown);
}
});
});
});
return fns;
}
test('DynamicGroupBy controlPanel label and description functions return strings', () => {
const fns = collectFunctionProps(config);
expect(fns.length).toBeGreaterThan(0);
fns.forEach(fn => {
expect(typeof fn()).toBe('string');
});
});

View File

@@ -49,12 +49,12 @@ const config: ControlPanelConfig = {
name: 'canSelectMultiple',
config: {
type: 'CheckboxControl',
label: t('Can select multiple values'),
label: () => t('Can select multiple values'),
default: true,
renderTrigger: true,
resetConfig: true,
affectsDataMask: true,
description: t('Allow users to select multiple values'),
description: () => t('Allow users to select multiple values'),
},
},
],
@@ -63,12 +63,13 @@ const config: ControlPanelConfig = {
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Chart customization value is required'),
label: () => t('Chart customization value is required'),
default: false,
renderTrigger: true,
description: t(
'User must select a value before applying the chart customization',
),
description: () =>
t(
'User must select a value before applying the chart customization',
),
},
},
],

View File

@@ -0,0 +1,53 @@
/**
* 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 config from './controlPanel';
type ControlConfig = {
label?: unknown;
description?: unknown;
};
type ControlItem = {
config: ControlConfig;
} | null;
function collectFunctionProps(cfg: typeof config) {
const fns: Array<() => unknown> = [];
cfg.controlPanelSections.forEach(section => {
section?.controlSetRows.forEach(row => {
(row as ControlItem[]).forEach(item => {
if (item && typeof item === 'object' && 'config' in item) {
const { label, description } = item.config;
if (typeof label === 'function') fns.push(label as () => unknown);
if (typeof description === 'function')
fns.push(description as () => unknown);
}
});
});
});
return fns;
}
test('TimeColumn controlPanel label and description functions return strings', () => {
const fns = collectFunctionProps(config);
expect(fns.length).toBeGreaterThan(0);
fns.forEach(fn => {
expect(typeof fn()).toBe('string');
});
});

View File

@@ -30,12 +30,11 @@ const config: ControlPanelConfig = {
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Filter value is required'),
label: () => t('Filter value is required'),
default: false,
renderTrigger: true,
description: t(
'User must select a value before applying the filter',
),
description: () =>
t('User must select a value before applying the filter'),
},
},
],

View File

@@ -0,0 +1,53 @@
/**
* 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 config from './controlPanel';
type ControlConfig = {
label?: unknown;
description?: unknown;
};
type ControlItem = {
config: ControlConfig;
} | null;
function collectFunctionProps(cfg: typeof config) {
const fns: Array<() => unknown> = [];
cfg.controlPanelSections.forEach(section => {
section?.controlSetRows.forEach(row => {
(row as ControlItem[]).forEach(item => {
if (item && typeof item === 'object' && 'config' in item) {
const { label, description } = item.config;
if (typeof label === 'function') fns.push(label as () => unknown);
if (typeof description === 'function')
fns.push(description as () => unknown);
}
});
});
});
return fns;
}
test('TimeGrain controlPanel label and description functions return strings', () => {
const fns = collectFunctionProps(config);
expect(fns.length).toBeGreaterThan(0);
fns.forEach(fn => {
expect(typeof fn()).toBe('string');
});
});

View File

@@ -30,12 +30,11 @@ const config: ControlPanelConfig = {
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Customization value is required'),
label: () => t('Customization value is required'),
default: false,
renderTrigger: true,
description: t(
'User must select a value before applying the customization',
),
description: () =>
t('User must select a value before applying the customization'),
},
},
],

View File

@@ -42,7 +42,10 @@ import {
getQuerySettings,
getChartDataUri,
} from 'src/explore/exploreUtils';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import {
addDangerToast,
addWarningToast,
} from 'src/components/MessageToasts/actions';
import { logEvent } from 'src/logger/actions';
import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
@@ -813,6 +816,11 @@ export function exploreJSON(
}),
),
);
(queriesResponse as QueryData[]).forEach(response => {
if (response.warning) {
dispatch(addWarningToast(response.warning, { noDuplicate: true }));
}
});
return dispatch(
chartUpdateSucceeded(queriesResponse as QueryData[], key as number),
);

View File

@@ -31,6 +31,7 @@ import {
AnnotationSourceType,
AnnotationStyle,
} from '@superset-ui/core';
import * as toastActions from 'src/components/MessageToasts/actions';
import { LOG_EVENT } from 'src/logger/actions';
import * as exploreUtils from 'src/explore/exploreUtils';
import * as actions from 'src/components/Chart/chartAction';
@@ -412,6 +413,56 @@ describe('chart actions', () => {
);
expect(result).toEqual([1, 2, 3]);
});
test('dispatches addWarningToast when a query response includes a warning', async () => {
const warningMessage =
'Results truncated to 1,000 rows due to memory constraints.';
fetchMock.removeRoute(MOCK_URL);
fetchMock.post(
`glob:*${MOCK_URL}*`,
{ result: [{ warning: warningMessage }] },
{ name: MOCK_URL },
);
const addWarningToastSpy = jest.spyOn(toastActions, 'addWarningToast');
const actionThunk = actions.postChartFormData(
{ viz_type: 'my_viz' } as QueryFormData,
false,
undefined,
undefined,
);
await actionThunk(
dispatch as unknown as actions.ChartThunkDispatch,
mockGetState as unknown as () => actions.RootState,
undefined,
);
expect(addWarningToastSpy).toHaveBeenCalledWith(warningMessage, {
noDuplicate: true,
});
addWarningToastSpy.mockRestore();
fetchMock.removeRoute(MOCK_URL);
setupDefaultFetchMock();
});
test('does not dispatch addWarningToast when no query response has a warning', async () => {
const addWarningToastSpy = jest.spyOn(toastActions, 'addWarningToast');
const actionThunk = actions.postChartFormData(
{ viz_type: 'my_viz' } as QueryFormData,
false,
undefined,
undefined,
);
await actionThunk(
dispatch as unknown as actions.ChartThunkDispatch,
mockGetState as unknown as () => actions.RootState,
undefined,
);
expect(addWarningToastSpy).not.toHaveBeenCalled();
addWarningToastSpy.mockRestore();
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks

View File

@@ -34,6 +34,7 @@ import { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
import { GetState, LayoutItem, RootState } from '../types';
import { updateLayoutComponents } from './dashboardFilters';
import { setUnsavedChanges } from './dashboardState';
type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
// Component CRUD -------------------------------------------------------------

View File

@@ -468,9 +468,12 @@ export function saveDashboardRequest(
);
const cleanedData: JsonObject = {
...data,
certified_by: certified_by || '',
certification_details:
certified_by && certification_details ? certification_details : '',
...(certified_by !== undefined && {
certified_by,
certification_details: certified_by
? (certification_details ?? '')
: '',
}),
css: css || '',
dashboard_title: dashboard_title || t('[ untitled dashboard ]'),
owners: ensureIsArray(owners as JsonObject[]).map((o: JsonObject) =>

View File

@@ -118,7 +118,7 @@ const NewChartButtonContainer = styled.div`
${({ theme }) => css`
display: flex;
justify-content: flex-end;
padding-right: ${theme.sizeUnit * 2}px;
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px 0;
`}
`;

View File

@@ -234,6 +234,26 @@ describe('ControlPanelsContainer', () => {
);
});
test('renders section with function label and description', async () => {
getChartControlPanelRegistry().remove('table');
getChartControlPanelRegistry().registerValue('table', {
controlPanelSections: [
{
label: () => t('Dynamic Section Label'),
description: () => t('Dynamic section description'),
expanded: true,
controlSetRows: [['groupby']],
},
],
});
render(<ControlPanelsContainer {...getDefaultProps()} />, {
useRedux: true,
});
await waitFor(() => {
expect(screen.getByText('Dynamic Section Label')).toBeInTheDocument();
});
});
test('hidden state of controls is correctly applied', async () => {
getChartControlPanelRegistry().remove('table');
getChartControlPanelRegistry().registerValue('table', {

View File

@@ -630,10 +630,17 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
line-height: 1.3;
`}
>
{label}
{typeof label === 'function' ? (label as () => ReactNode)() : label}
</span>{' '}
{description && (
<Tooltip id={sectionId} title={description}>
<Tooltip
id={sectionId}
title={
typeof description === 'function'
? (description as () => ReactNode)()
: description
}
>
<Icons.InfoCircleOutlined css={iconStyles} />
</Tooltip>
)}

View File

@@ -125,7 +125,7 @@ const getTooltipTitle = (
) =>
isLabelTruncated ? (
<div>
{label && <strong>{label}</strong>}
{label && <strong>{t(label)}</strong>}
{range && (
<div
css={(theme: SupersetTheme) => css`
@@ -160,7 +160,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
const [timeRangeValue, setTimeRangeValue] = useState(value);
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
const [evalResponse, setEvalResponse] = useState<string>(value);
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(value);
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(t(value));
const theme = useTheme();
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
@@ -175,7 +175,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
if (error) {
setEvalResponse(error || '');
setValidTimeRange(false);
setTooltipTitle(value || null);
setTooltipTitle(t(value) || null);
} else {
/*
HRT == human readable text

View File

@@ -486,6 +486,70 @@ test('sets comparator to undefined when operator is IS_NULL or IS_NOT_NULL', ()
});
});
test('hides the value input when operator is IS_NULL', () => {
setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: Operators.IsNull,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsNull].operation,
comparator: undefined,
clause: Clauses.Where,
}),
});
expect(
screen.queryByPlaceholderText('Filter value (case sensitive)'),
).not.toBeInTheDocument();
});
test('hides the value input when operator is IS_NOT_NULL', () => {
setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: Operators.IsNotNull,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsNotNull].operation,
comparator: undefined,
clause: Clauses.Where,
}),
});
expect(
screen.queryByPlaceholderText('Filter value (case sensitive)'),
).not.toBeInTheDocument();
});
test('hides the value input when operator is IS_TRUE', () => {
setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: Operators.IsTrue,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsTrue].operation,
comparator: undefined,
clause: Clauses.Where,
}),
});
expect(
screen.queryByPlaceholderText('Filter value (case sensitive)'),
).not.toBeInTheDocument();
});
test('hides the value input when operator is IS_FALSE', () => {
setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: Operators.IsFalse,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsFalse].operation,
comparator: undefined,
clause: Clauses.Where,
}),
});
expect(
screen.queryByPlaceholderText('Filter value (case sensitive)'),
).not.toBeInTheDocument();
});
test('should not call API when column has no advanced data type', async () => {
const props = getAdvancedDataTypeTestProps();

View File

@@ -377,6 +377,14 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
const shouldFocusComparator =
!!subjectSelectProps.value && !!operatorSelectProps.value;
const isUnaryOperator =
operatorId !== undefined &&
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators);
const hasComparatorOptions =
(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
suggestions.length > 0;
const comparatorSelectProps = {
allowClear: true,
allowNewOptions: true,
@@ -389,9 +397,6 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
value: comparator as SelectValue,
onChange: onComparatorChange,
notFoundContent: t('Type a value here'),
disabled:
operatorId !== undefined &&
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators),
placeholder: createSuggestionsPlaceholder(),
};
@@ -554,49 +559,45 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
}))}
{...operatorSelectProps}
/>
{(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
suggestions.length > 0 ? (
<Tooltip
title={
advancedDataTypesState.errorMessage ||
advancedDataTypesState.parsedAdvancedDataType
}
>
<SelectWithLabel
css={css`
margin-top: ${theme.marginXS}px;
`}
labelText={labelText}
options={suggestions}
{...comparatorSelectProps}
/>
</Tooltip>
) : (
<Tooltip
title={
advancedDataTypesState.errorMessage ||
advancedDataTypesState.parsedAdvancedDataType
}
>
<div
css={css`
margin-top: ${theme.marginXS}px;
`}
/>
<Input
data-test="adhoc-filter-simple-value"
name="filter-value"
ref={comparatorInputRef}
onChange={onInputComparatorChange}
value={typeof comparator === 'string' ? comparator : undefined}
placeholder={t('Filter value (case sensitive)')}
disabled={
operatorId !== undefined &&
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators)
{!isUnaryOperator &&
(hasComparatorOptions ? (
<Tooltip
title={
advancedDataTypesState.errorMessage ||
advancedDataTypesState.parsedAdvancedDataType
}
/>
</Tooltip>
)}
>
<SelectWithLabel
css={css`
margin-top: ${theme.marginXS}px;
`}
labelText={labelText}
options={suggestions}
{...comparatorSelectProps}
/>
</Tooltip>
) : (
<Tooltip
title={
advancedDataTypesState.errorMessage ||
advancedDataTypesState.parsedAdvancedDataType
}
>
<div
css={css`
margin-top: ${theme.marginXS}px;
`}
/>
<Input
data-test="adhoc-filter-simple-value"
name="filter-value"
ref={comparatorInputRef}
onChange={onInputComparatorChange}
value={typeof comparator === 'string' ? comparator : undefined}
placeholder={t('Filter value (case sensitive)')}
/>
</Tooltip>
))}
</>
);
return (

View File

@@ -62,6 +62,8 @@ const StyledSyntaxContainer = styled.div`
const StyledThemedSyntaxHighlighter = styled(CodeSyntaxHighlighter)`
flex: 1;
height: ${({ theme }) => theme.sizeUnit * 26}px;
margin-top: 0;
`;
const StyledFooter = styled.div`
@@ -163,7 +165,12 @@ const ViewQuery: FC<ViewQueryProps> = props => {
) : (
<StyledThemedSyntaxHighlighter
language={language}
customStyle={{ flex: 1, marginBottom: theme.sizeUnit * 3 }}
customStyle={{
flex: 1,
marginBottom: theme.sizeUnit * 3,
fontSize: theme.fontSize * 0.75,
padding: 0,
}}
>
{currentSQL}
</StyledThemedSyntaxHighlighter>

View File

@@ -0,0 +1,96 @@
/**
* 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 { ControlProps } from '@jsonforms/core';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import { MultiEnumControl } from './jsonFormsHelpers';
const baseProps = (overrides: Partial<ControlProps> = {}): ControlProps =>
({
label: 'Tags',
path: 'tags',
enabled: true,
schema: {
type: 'array',
items: {
enum: ['a', 'b', 'c'],
'x-enumNames': ['Apple', 'Banana', 'Cherry'],
},
},
uischema: { type: 'Control', scope: '#/properties/tags', options: {} },
data: [],
handleChange: jest.fn(),
config: {},
...overrides,
}) as unknown as ControlProps;
test('renders enum labels from items.x-enumNames', async () => {
render(<MultiEnumControl {...baseProps()} />);
await userEvent.click(screen.getByRole('combobox'));
expect(await screen.findByText('Apple')).toBeInTheDocument();
expect(screen.getByText('Banana')).toBeInTheDocument();
expect(screen.getByText('Cherry')).toBeInTheDocument();
});
test('falls back to raw enum values when x-enumNames is absent', async () => {
const props = baseProps({
schema: { type: 'array', items: { enum: ['red', 'green'] } },
});
const { container } = render(<MultiEnumControl {...props} />);
await userEvent.click(screen.getByRole('combobox'));
await screen.findAllByText('red');
const options = container.ownerDocument.querySelectorAll(
'.ant-select-item-option-content',
);
expect(Array.from(options).map(el => el.textContent)).toEqual([
'red',
'green',
]);
});
test('emits the new array via handleChange when an option is picked', async () => {
const handleChange = jest.fn();
render(<MultiEnumControl {...baseProps({ handleChange })} />);
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByText('Banana'));
expect(handleChange).toHaveBeenLastCalledWith('tags', ['b']);
});
test('renders existing data as selected tags using x-enumNames labels', () => {
render(<MultiEnumControl {...baseProps({ data: ['a', 'c'] })} />);
// Selected items render in a hidden listbox with role=option,
// but the tag text is the user-visible label.
expect(screen.getByText('Apple')).toBeInTheDocument();
expect(screen.getByText('Cherry')).toBeInTheDocument();
expect(screen.queryByText('Banana')).not.toBeInTheDocument();
});
test('shows a loading state when config.refreshingSchema is true', () => {
const { container } = render(
<MultiEnumControl {...baseProps({ config: { refreshingSchema: true } })} />,
);
expect(container.querySelector('.ant-select-arrow-loading')).toBeTruthy();
});
test('treats non-array data as an empty selection without crashing', () => {
render(<MultiEnumControl {...baseProps({ data: undefined })} />);
// No tags rendered when data is missing
expect(screen.queryByText('Apple')).not.toBeInTheDocument();
expect(screen.queryByText('Banana')).not.toBeInTheDocument();
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { JsonSchema } from '@jsonforms/core';
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
import {
areDependenciesSatisfied,
@@ -24,8 +24,16 @@ import {
buildUiSchema,
getDynamicDependencies,
serializeDependencyValues,
multiEnumEntry,
enumNamesEntry,
} from './jsonFormsHelpers';
const control = {
type: 'Control',
scope: '#',
} as unknown as UISchemaElement;
const ctx = { rootSchema: {} as JsonSchema, config: {} };
test('areDependenciesSatisfied returns true for present dependency values', () => {
expect(
areDependenciesSatisfied(['database', 'schema'], {
@@ -148,3 +156,38 @@ test('serializeDependencyValues is stable and sorted by key', () => {
JSON.stringify({ database: 'analytics', warehouse: 'compute_wh' }),
);
});
test('multiEnumEntry.tester matches array schemas with non-empty items.enum', () => {
const schema = {
type: 'array',
items: { enum: ['a', 'b'] },
} as unknown as JsonSchema;
expect(multiEnumEntry.tester(control, schema, ctx)).toBe(35);
});
test.each([
['empty items.enum', { type: 'array', items: { enum: [] } }],
['items without enum', { type: 'array', items: { type: 'string' } }],
['array without items', { type: 'array' }],
['scalar enum', { type: 'string', enum: ['a', 'b'] }],
])('multiEnumEntry.tester does not match %s', (_label, schema) => {
expect(multiEnumEntry.tester(control, schema as JsonSchema, ctx)).toBe(-1);
});
test('enumNamesEntry.tester matches scalar enum with x-enumNames', () => {
const schema = {
type: 'string',
enum: ['a', 'b'],
'x-enumNames': ['Alpha', 'Beta'],
} as JsonSchema;
expect(enumNamesEntry.tester(control, schema, ctx)).toBe(5);
});
test('enumNamesEntry.tester does not match array schemas (multiEnum owns those)', () => {
const schema = {
type: 'array',
items: { enum: ['a'], 'x-enumNames': ['Alpha'] },
'x-enumNames': ['Alpha'],
} as JsonSchema;
expect(enumNamesEntry.tester(control, schema, ctx)).toBe(-1);
});

View File

@@ -252,25 +252,108 @@ function EnumNamesControl(props: ControlProps) {
);
}
const EnumNamesRenderer = withJsonFormsControlProps(EnumNamesControl);
const enumNamesEntry = {
export const enumNamesEntry = {
// Rank 5: higher than the default string renderer (23) so this fires
// whenever x-enumNames is present, regardless of the underlying type.
// Array-of-enum schemas are handled by ``multiEnumEntry`` below — this
// renderer only targets scalar string/number controls.
tester: rankWith(
5,
schemaMatches(s => {
const names = (s as Record<string, unknown>)['x-enumNames'];
return Array.isArray(names) && (names as unknown[]).length > 0;
}),
and(
schemaMatches(s => {
const names = (s as Record<string, unknown>)['x-enumNames'];
return Array.isArray(names) && (names as unknown[]).length > 0;
}),
schemaMatches(s => (s as Record<string, unknown>)?.type !== 'array'),
),
),
renderer: EnumNamesRenderer,
};
/**
* Renderer for ``{type: 'array', items: {enum: [...]}}`` schemas. Renders
* a single Antd Select with ``mode="multiple"`` (tag-style multi-select),
* matching the natural expectation of a "pick several from a list" control.
*
* Without this, the default ``PrimitiveArrayControl`` from the upstream
* library renders an "Add …" button that creates one single-select per
* element — visually wrong for an enum multi-select and unable to display
* ``items.x-enumNames`` labels.
*
* The renderer is dynamic-aware: when the host form is refreshing the
* schema (e.g. compatible options narrowing as the user picks), the Select
* shows a loading indicator without becoming disabled, so the user can
* continue editing while options refresh.
*/
export function MultiEnumControl(props: ControlProps) {
const { refreshingSchema } = props.config ?? {};
const arraySchema = props.schema as Record<string, unknown>;
const itemsSchema =
(arraySchema.items as Record<string, unknown>) ??
({} as Record<string, unknown>);
const enumValues = (itemsSchema.enum as unknown[]) ?? [];
const enumNames =
(itemsSchema['x-enumNames'] as string[]) ?? enumValues.map(String);
const options = enumValues.map((value, index) => ({
value: value as string | number,
label: enumNames[index] ?? String(value),
}));
const value = Array.isArray(props.data) ? (props.data as unknown[]) : [];
const tooltip = (props.uischema?.options as Record<string, unknown>)
?.tooltip as string | undefined;
return (
<Form.Item label={props.label} tooltip={tooltip}>
<Select
mode="multiple"
value={value as (string | number)[]}
onChange={next => props.handleChange(props.path, next)}
options={options}
style={{ width: '100%' }}
disabled={!props.enabled}
loading={!!refreshingSchema}
allowClear
optionFilterProp="label"
placeholder={
(props.uischema?.options as Record<string, unknown>)
?.placeholderText as string | undefined
}
/>
</Form.Item>
);
}
const MultiEnumRenderer = withJsonFormsControlProps(MultiEnumControl);
export const multiEnumEntry = {
// Rank 35: must beat upstream ``PrimitiveArrayRenderer`` (rank 30) so an
// ``array``/``items.enum`` schema renders as one Antd multi-select tag
// box instead of the "Add" repeater pattern that PrimitiveArray uses.
tester: rankWith(
35,
schemaMatches(s => {
const schema = s as Record<string, unknown>;
if (schema?.type !== 'array') return false;
const items = schema.items as Record<string, unknown> | undefined;
return (
!!items &&
Array.isArray(items.enum) &&
(items.enum as unknown[]).length > 0
);
}),
),
renderer: MultiEnumRenderer,
};
export const renderers = [
...rendererRegistryEntries,
passwordEntry,
constEntry,
readOnlyEntry,
enumNamesEntry,
multiEnumEntry,
dynamicFieldEntry,
];

View File

@@ -254,7 +254,10 @@ export default function AddSemanticViewModal({
!schema?.properties ||
Object.keys(schema.properties).length === 0
) {
// No runtime config needed — fetch views right away
// Preserve top-level runtime metadata (e.g. x-singleView) even when
// there are no form fields, then fetch views right away. Skip the
// apply call entirely if the backend returned no schema at all.
if (schema) applyRuntimeSchema(schema);
fetchViews(uuid, {}, gen);
} else {
applyRuntimeSchema(schema);
@@ -456,6 +459,31 @@ export default function AddSemanticViewModal({
const viewsDisabled =
loadingViews || (!loadingViews && availableViews.length === 0);
// When ``x-singleView: true`` the runtime form fully describes a single
// semantic view (e.g. a MetricFlow cube). Hide the picker and auto-select
// whatever ``get_semantic_views`` returned so the Add button can fire
// without an extra user click.
const singleViewMode =
(runtimeSchema as Record<string, unknown> | null)?.['x-singleView'] ===
true;
useEffect(() => {
if (!singleViewMode) return;
const namesToAdd = availableViews
.filter(v => !v.already_added)
.map(v => v.name)
.slice(0, 1);
setSelectedViewNames(prev => {
if (
prev.length === namesToAdd.length &&
prev.every((n, i) => n === namesToAdd[i])
) {
return prev;
}
return namesToAdd;
});
}, [singleViewMode, availableViews]);
return (
<StandardModal
show={show}
@@ -511,8 +539,12 @@ export default function AddSemanticViewModal({
</>
)}
{/* Semantic Views — always visible once a layer is selected */}
{selectedLayerUuid && !loadingRuntime && (
{/* Semantic Views — always visible once a layer is selected, unless
the runtime schema declares ``x-singleView: true``: extensions
(e.g. MetricFlow cubes) whose runtime form fully describes a
single view set that flag so the picker disappears and the
view is auto-selected when ``get_semantic_views`` returns it. */}
{selectedLayerUuid && !loadingRuntime && !singleViewMode && (
<ModalFormField label={t('Semantic Views')}>
<Select
ariaLabel={t('Semantic views')}

View File

@@ -94,7 +94,7 @@ function UserListModal({
} else {
try {
await createUser(values);
addSuccessToast(t('The group has been created successfully.'));
addSuccessToast(t('The user has been created successfully.'));
} catch (err) {
await handleError(err, Actions.CREATE);
}

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