Compare commits

..

242 Commits

Author SHA1 Message Date
kasiazjc
aff407d730 fix(listview): fix compact filter pill tests and type error
- Select.test.tsx: migrate selectOption → selectPillOption for compact
  pill UI; update rehydration assertion to use data-test attr; wrap
  clearFilter() call in act() to silence React state warning
- CompactSelectPanel.test.tsx: add rerender after clearFilter() to
  reflect fully-controlled component no longer having internal state
- CompactSelectPanel.tsx: use opt.title for ReactNode labels in
  handleSelect so owner-select options serialize correctly
- CompactFilterTrigger.tsx: prettier formatting
- chartAction.ts: cast response to include warning field to fix TS error
  (ChartDataResponseResult in installed .d.ts predates warning field)
- scripts/oxlint.sh: fix silent exit-1 when oxlint produces no output
  (empty-string && short-circuit was the last command, so it became the
  script's exit code; use if/then/fi instead)
2026-05-26 06:04:25 +00:00
Alexandru Soare
ac778078de feat(mcp): make form_data_key optional in update_chart_preview (#39680) 2026-05-26 06:04:25 +00:00
Alexandru Soare
fc27892a7d chore(mcp): Simplify chart preview response (#40020) 2026-05-26 06:04:25 +00:00
xavier-GitHub76
9c2a98d29f fix(UserListModal): Success notification mentions user and not group (#40284) 2026-05-26 06:04:25 +00:00
dependabot[bot]
49e900ba75 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-26 06:04:25 +00:00
Evan Rusackas
b2b7d737b5 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-26 06:04:25 +00:00
Evan Rusackas
4e34961d10 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-26 06:04:25 +00:00
Jean Massucatto
0f8bdfffb9 fix(explore): hide value input for unary filter operators (#39924) 2026-05-26 06:04:25 +00:00
Evan Rusackas
d0a72c572c 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-26 06:04:25 +00:00
Torsten Stöter
863f8b0b5c docs: remove out-of-place phrase (#40226) 2026-05-26 06:04:25 +00:00
Abdul Rehman
e5e90ed131 fix(frontend): handle null/undefined path in ensureAppRoot (#39940)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-26 06:04:25 +00:00
dependabot[bot]
7523f08433 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-26 06:04:25 +00:00
dependabot[bot]
43d14d13da 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-26 06:04:25 +00:00
Evan Rusackas
68f0aecc79 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-26 06:04:25 +00:00
Evan Rusackas
83fd7cea81 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-26 06:04:25 +00:00
Evan Rusackas
33ee1826f7 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-26 06:04:25 +00:00
Evan Rusackas
809692fb61 ci(docker): pin QEMU binfmt image to stabilize arm64 builds (#40235)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:25 +00:00
Evan Rusackas
9ebc9d42c6 ci(e2e): run backend under gunicorn instead of flask dev server (#40234)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:25 +00:00
Evan Rusackas
25a80cf3ed 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-26 06:04:24 +00:00
Evan Rusackas
f194fbe1da 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-26 06:04:24 +00:00
yousoph
02d365aba6 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-26 06:04:24 +00:00
Miha Rejec
4eb4d1ce44 fix(i18n): translate DateFilter tooltip for time range values (#40286)
Co-authored-by: Miha Rejec <mihar@comland.si>
2026-05-26 06:04:24 +00:00
dependabot[bot]
c59e928fd2 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-26 06:04:24 +00:00
dependabot[bot]
0f1f274037 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-26 06:04:24 +00:00
dependabot[bot]
d99f2caf0a 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-26 06:04:24 +00:00
dependabot[bot]
4aefa4a627 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-26 06:04:24 +00:00
dependabot[bot]
3b06d452c8 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-26 06:04:24 +00:00
dependabot[bot]
1148ad14f4 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-26 06:04:24 +00:00
dependabot[bot]
c0564c77af 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-26 06:04:24 +00:00
dependabot[bot]
ee03b18bfa 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-26 06:04:24 +00:00
dependabot[bot]
a1bbdd06ca 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-26 06:04:24 +00:00
Joe Li
4f7164681f fix(select): replace cached options with search results in AsyncSelect (#40039)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:24 +00:00
Đỗ Trọng Hải
c4d632a0b1 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-26 06:04:24 +00:00
Ali Gouta
bd1751ae3f fix(chart): fix label and description translation on UI and enhance french translations (#40229)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-26 06:04:24 +00:00
Evan Rusackas
59a236a566 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-26 06:04:24 +00:00
Beto Dealmeida
89b87591a0 feat(semantic layers): form for SL with a single SV (#40280) 2026-05-26 06:04:24 +00:00
Mehmet Salih Yavuz
466df0e657 fix(mcp): use name URL param so AI-generated SQL Lab titles render (#40288) 2026-05-26 06:04:24 +00:00
dependabot[bot]
8d8b76f3a0 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-26 06:04:24 +00:00
dependabot[bot]
f9131a7784 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-26 06:04:24 +00:00
Đỗ Trọng Hải
3c0f4e8852 chore(backend/build): upgrade Gunicorn from v22 to v25 (#38788)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-26 06:04:24 +00:00
Đỗ Trọng Hải
39cd510e97 fix(docker): add missing service-worker.js into built container image (#39596)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-26 06:04:24 +00:00
dependabot[bot]
582ffc3de1 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-26 06:04:24 +00:00
Shaitan
343a754108 docs: expand out-of-scope vulnerability definitions (#40332) 2026-05-26 06:04:24 +00:00
dependabot[bot]
198a25ed35 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-26 06:04:24 +00:00
Miha Rejec
2a73a3298a fix(i18n): add Slovenian translation for 'Range type' in DateFilter (#40287)
Co-authored-by: Miha Rejec <mihar@comland.si>
2026-05-26 06:04:24 +00:00
David Kopelent
c71de697cf feat(i18n): add missing Slovak translations (#40219) 2026-05-26 06:04:24 +00:00
Alexandru Soare
12a3cd97fd fix(preview): fix chart preview bugs (#40063) 2026-05-26 06:04:24 +00:00
Amin Ghadersohi
471cd89d5f feat(mcp): add series_limit to generate_chart XY config (#40307)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:24 +00:00
chaselynisabella
b467f9ab95 feat(path): support metric-based color scales & line width by metric (#39165) 2026-05-26 06:04:24 +00:00
Amin Ghadersohi
34f847fff7 fix(mcp): hide write tools from users without write permissions (#40098)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:24 +00:00
dependabot[bot]
5d3b8309ac 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-26 06:04:24 +00:00
dependabot[bot]
cff42bae8f 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-26 06:04:24 +00:00
dependabot[bot]
297fb5211e 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-26 06:04:24 +00:00
dependabot[bot]
a2e4e5b62c 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-26 06:04:24 +00:00
dependabot[bot]
15f7a7c9d8 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-26 06:04:23 +00:00
Evan Rusackas
9a55927575 fix(TableView): reset pagination when data reduces below current page (#34562)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
JUST.in DO IT
2abe47cdfa fix(view query): Update style for code viewer container (#39635)
Co-authored-by: Copilot <copilot@github.com>
2026-05-26 06:04:23 +00:00
Jay Masiwal
1d873ea96b fix(frontend): update safeStringify to surface [Circular] and DRY plugin code (#39156) 2026-05-26 06:04:23 +00:00
Evan Rusackas
527f127f93 fix(datasets): isolate filter state to fix concurrent /dataset race (#39685)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Elizabeth Thompson
13e32fb3ff fix(reports): guard null dashboard height in Playwright screenshots (#40179) 2026-05-26 06:04:23 +00:00
Mehmet Salih Yavuz
189a55549b feat(mcp): make config optional in generate_explore_link (#39559) 2026-05-26 06:04:23 +00:00
Mehmet Salih Yavuz
afef786419 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-26 06:04:23 +00:00
Mehmet Salih Yavuz
c2b1e1e539 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-26 06:04:23 +00:00
Beto Dealmeida
e9e2a93105 fix(semantic layers): coerce filter types (#40222) 2026-05-26 06:04:23 +00:00
Mehmet Salih Yavuz
43f6edf2d2 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-26 06:04:23 +00:00
Alexandru Soare
6941f69396 fix(recommandation): Fix chart recommandation (#39886) 2026-05-26 06:04:23 +00:00
Mehmet Salih Yavuz
2797b4e3ed feat(mcp): chart formatting options across all supported chart types (#39887) 2026-05-26 06:04:23 +00:00
dependabot[bot]
a3ea617aa4 chore(deps): bump markdown-to-jsx from 9.8.0 to 9.8.1 in /superset-frontend (#40316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:23 +00:00
dependabot[bot]
66090905e5 chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40313)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:23 +00:00
dependabot[bot]
18c2da79b4 chore(deps-dev): bump oxlint from 1.65.0 to 1.66.0 in /superset-frontend (#40318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
aefa459e89 fix(charts): handle PostgreSQL INTERVAL type in bar and pie charts (#34513)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
5523a416da fix(sqllab): keep saved-query list working when Jinja dataset(id) references a deleted dataset (#39703)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Maxime Beauchemin
af069f93ff fix(tests): fix flaky FileHandler test by awaiting LaunchQueue consumer in afterEach (#39508)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: sadpandajoe <jcli38@gmail.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
fa8cfa1f9b ci(deps): bump lower bound on pip dependabot PRs (#40308)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Maxime Beauchemin
3415a61087 fix(list-view): preserve user name in filter pill after navigation (#39505)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
b0024d7a36 chore(sql-lab): finish SqlLab typed-dispatch migration for SaveDatasetModal (#40040)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
3b96e6f471 chore(superset-ui-switchboard): forward-compat fixes for TypeScript 6.0 (Phase E) (#40028)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
6842bb3186 chore(sql-lab): migrate useDispatch to useAppDispatch (#40037)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Beto Dealmeida
97dd0fb58a feat(semantic layers): add metadata on additive metrics (#40279) 2026-05-26 06:04:23 +00:00
Evan Rusackas
e8b6a9f674 fix(dashboard-import): remap chartsInScope on import (#26338) (#40140)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
519606e93a test(dashboard-import): pin native filter scope rootPath preservation (#19944) (#40135)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
712b29df55 test(datasets): regression test for Jinja not rendered on sync columns (#25839) (#40224)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
81991e5696 test(charts): regression for last-modified sort order (#27500) (#40231)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
51cb17c85b test(reports): regression for alerts CSV missing chart time filters (#25538) (#40232)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
1385c05ed4 test(helpers): regression for humanize locale activation (#28331) (#40233)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Evan Rusackas
982c0208b3 chore(codeowners): add @rusackas as translations maintainer (#40295)
Co-authored-by: Claude <claude@anthropic.com>
2026-05-26 06:04:23 +00:00
dependabot[bot]
d58252b7a7 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.59.3 to 8.59.4 in /superset-websocket (#40250)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:23 +00:00
dependabot[bot]
4ba113e9b4 chore(deps): bump content-disposition from 1.1.0 to 2.0.0 in /superset-frontend (#40109)
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>
2026-05-26 06:04:23 +00:00
Shaitan
38f1bef50a fix(reports): enforce server-side recipient on chart/dashboard report subscriptions (#38847)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
Mike Bridge
4cc71f49e6 fix(dataset-editor): drop null warning_markdown from extra JSON serialisation (#39706)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:04:23 +00:00
dependabot[bot]
04aa096a73 chore(deps-dev): bump react-resizable and @types/react-resizable in /superset-frontend (#40110)
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-26 06:04:23 +00:00
SkinnyPigeon
86580d3693 docs(reports): playwright setup clarification (#40168) 2026-05-26 06:04:22 +00:00
Evan Rusackas
5348a68510 ci(docs): soft-fail badge localization on transient fetch errors (#40236)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:22 +00:00
Evan Rusackas
b093a0357c test(sql-parser): pin WITH+UNION as non-mutating across dialects (#25659) (#40138)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
0b0b887b4a chore(deps-dev): bump typescript-eslint from 8.59.3 to 8.59.4 in /superset-websocket (#40251)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
ac3d3f687b chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.59.3 to 8.59.4 in /superset-frontend (#40256)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
57c44bf1d4 chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40262)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
45849c4116 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40265)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
15054d4298 chore(deps): bump zod from 4.4.1 to 4.4.3 in /superset-frontend (#40272)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
b89eca9141 chore(deps-dev): bump webpack from 5.106.2 to 5.107.0 in /docs (#40291)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
6f8d9e61a9 chore(deps-dev): bump ts-jest from 29.4.9 to 29.4.10 in /superset-websocket (#40290)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
jesperct
a5002f7709 fix(echarts): preserve dataZoom range across setOption(notMerge) (#40173) 2026-05-26 06:04:22 +00:00
Alexandru Soare
60cc8ccab4 fix(mcp): Skip misleading trend analysis for categorical ASCII charts (#39761) 2026-05-26 06:04:22 +00:00
Alexandru Soare
b774a5018d fix(mcp): raise right error (#39964) 2026-05-26 06:04:22 +00:00
Alexandru Soare
48bd635065 feat(mcp): Add mcp_call_id to tool responses for server log correlation (#39776) 2026-05-26 06:04:22 +00:00
Alexandru Soare
8261f40705 fix(mcp): Block destructive DDL (DROP, TRUNCATE, ALTER) in execute_sql (#39621) 2026-05-26 06:04:22 +00:00
Alexandru Soare
149501c879 fix(mcp): changed_on_humanized null in write tool responses (generate_dashboard, generate_chart) (#39488) 2026-05-26 06:04:22 +00:00
Jean Massucatto
bf7bd149ff fix(sqllab): handle scientific notation in big number JSON responses (#39994) 2026-05-26 06:04:22 +00:00
dependabot[bot]
bfaac143be chore(deps-dev): bump @types/node from 25.8.0 to 25.9.1 in /superset-websocket (#40249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
5983d542e3 chore(deps-dev): bump @typescript-eslint/parser from 8.59.3 to 8.59.4 in /superset-websocket (#40252)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
729499dc43 chore(deps-dev): bump typescript-eslint from 8.59.3 to 8.59.4 in /docs (#40254)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
719572264f chore(deps): bump baseline-browser-mapping from 2.10.30 to 2.10.31 in /docs (#40255)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
7738dd8f9e chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#40269)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
d62f92a685 chore(deps): bump mapbox-gl from 3.23.1 to 3.24.0 in /superset-frontend (#40258)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
93b0e2ab2d chore(deps-dev): bump ts-jest from 29.4.9 to 29.4.10 in /superset-frontend (#40260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
d49030169c chore(deps-dev): bump @typescript-eslint/parser from 8.59.3 to 8.59.4 in /superset-frontend (#40263)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
7f16e9eab7 chore(deps-dev): bump tsx from 4.22.0 to 4.22.3 in /superset-frontend (#40267)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
1892c16b97 chore(deps-dev): bump @types/node from 25.8.0 to 25.9.1 in /superset-frontend (#40268)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
b5f5def641 chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40271)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
Evan Rusackas
e1a407d68e test(sql-parser): pin TimescaleDB hyperfunctions parse on postgresql (#32028) (#40142)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:22 +00:00
Evan Rusackas
26ab78695c docs: hide Component Playground top-level nav item (#40247)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:22 +00:00
madhushreeag
571b997c08 fix(roles): prevent 404 and silent user removal on large role edits (#40178)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
502cd76d69 chore(deps): bump swagger-ui-react from 5.32.5 to 5.32.6 in /docs (#40056)
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>
2026-05-26 06:04:22 +00:00
Evan Rusackas
18694e8bcf chore(deps): coordinated bump jest 30.3→30.4 + jest-environment-jsdom 29→30 (#40206)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:22 +00:00
dependabot[bot]
0d7655b712 chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#39936)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-26 06:04:22 +00:00
dependabot[bot]
4a5c76b358 chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.10.0 to 0.10.1 in /superset-frontend (#39902)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-26 06:04:22 +00:00
Evan Rusackas
c8f6a606d2 test(sql-parser): pin quoted identifiers with spaces are not subqueries (#32541, #32684) (#40143)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:21 +00:00
Beto Dealmeida
cb853fe5b1 fix: OAuth2 trigger (#40097) 2026-05-26 06:04:21 +00:00
Elizabeth Thompson
b56442ef74 fix(mcp): exclude self-referencing filter columns from get_schema output (#39826)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-05-26 06:04:21 +00:00
alex
987cd1e91d fix(deckgl): emit usable cross-filter values from polygon and geojson clicks (#39906)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:04:21 +00:00
Evan Rusackas
2fd5492ee0 docs: cut 6.1.0 versions for user_docs, admin_docs, developer_docs, components (#40126)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
0bf1958186 chore(deps): update dompurify requirement from ^3.4.3 to ^3.4.5 in /superset-frontend/plugins/legacy-preset-chart-nvd3 (#40213)
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>
2026-05-26 06:04:21 +00:00
dependabot[bot]
88203bdb63 chore(deps): update reselect requirement from ^5.1.1 to ^5.2.0 in /superset-frontend/packages/superset-ui-core (#40214)
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>
2026-05-26 06:04:21 +00:00
dependabot[bot]
00858d0af8 chore(deps): update dompurify requirement from ^3.4.2 to ^3.4.5 in /superset-frontend/packages/superset-ui-core (#40216)
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>
2026-05-26 06:04:21 +00:00
dependabot[bot]
888cf905cf chore(deps): bump webpack-dev-server from 5.2.2 to 5.2.4 in /docs (#40227)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
706c45fa92 chore(deps-dev): bump oxlint from 1.63.0 to 1.64.0 in /superset-frontend (#40160)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-26 06:04:21 +00:00
jesperct
e33a9973d6 fix(echarts): suppress phantom x-axis label at axis edge when no time grain (#39972) 2026-05-26 06:04:21 +00:00
Jean Massucatto
092bcd0da8 fix(explore): prevent unnecessary scrollbars during chart rendering (#39291) 2026-05-26 06:04:21 +00:00
Richard Fogaca Nienkotter
00bd9d2ac1 fix(deckgl): render all MultiPolygon parts in Polygon chart (#40100) 2026-05-26 06:04:21 +00:00
Vitor Avila
c7c3d411c6 fix(OAuth2): Re-query the OAuth2 token to avoid stale reference (#40071) 2026-05-26 06:04:21 +00:00
Evan Rusackas
211f7bd87c chore(deps): coordinated bump ag-grid-community + ag-grid-react 35.2.1→35.3.0 (#40205)
Co-authored-by: Claude <claude@anthropic.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
cb89f9de0f chore(deps): bump baseline-browser-mapping from 2.10.29 to 2.10.30 in /docs (#40211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
SkinnyPigeon
89b3ae845c feat: Allow specific mcp tools to be disabled (#39835) 2026-05-26 06:04:21 +00:00
Evan Rusackas
7d0a3364af test(prophet): pin yhat_lower can be negative for negative series (#21734) (#40141)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:21 +00:00
Evan Rusackas
87c848c2f1 test(security): regression test for session cookie after logout (#24713) (#40201)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:21 +00:00
Evan Rusackas
2531a166bd test(api): regression test for Admin empty dashboard/chart list (#25890) (#40202)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:21 +00:00
Mafi
c7d8bc55c1 fix(sqllab): execute prequeries on streaming connection to fix PostgreSQL CSV export (#40194)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
357ed59076 chore(deps-dev): bump ip-address from 10.1.0 to 10.2.0 in /superset-frontend (#40199)
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>
2026-05-26 06:04:21 +00:00
dependabot[bot]
a4040c7778 chore(deps-dev): bump eslint from 10.3.0 to 10.4.0 in /superset-websocket (#40208)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
60061b9ee9 chore(deps): bump reselect from 5.1.1 to 5.2.0 in /docs (#40209)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
096681eb03 chore(deps): bump antd from 6.4.2 to 6.4.3 in /docs (#40210)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
d4ecb1ba6f chore(deps): bump caniuse-lite from 1.0.30001792 to 1.0.30001793 in /docs (#40212)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
b4a831f6fc chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40215)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
6ec5e05d9b chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40217)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
dependabot[bot]
b5176e17fd chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#40218)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:21 +00:00
Alejandro Solares
9ca6ccbe3a fix(deps): patch fast-xml-parser CVE-2026-33036 and CVE-2026-33349 (#40118) 2026-05-26 06:04:21 +00:00
dependabot[bot]
bf215f722c chore(deps): update dompurify requirement from ^3.4.1 to ^3.4.2 in /superset-frontend/packages/superset-ui-core (#39808)
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>
2026-05-26 06:04:20 +00:00
dependabot[bot]
d44ed6ed82 chore(deps-dev): update jest requirement from ^30.3.0 to ^30.4.2 in /superset-frontend/plugins/plugin-chart-handlebars (#40015)
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-26 06:04:20 +00:00
dependabot[bot]
60d2755b65 chore(deps-dev): update jest requirement from ^30.3.0 to ^30.4.2 in /superset-frontend/plugins/plugin-chart-pivot-table (#40018)
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-26 06:04:20 +00:00
dependabot[bot]
9d6f99adec chore(deps-dev): update jest requirement from ^30.3.0 to ^30.4.2 in /superset-frontend/packages/generator-superset (#40019)
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-26 06:04:20 +00:00
dependabot[bot]
001157a777 chore(deps): update dompurify requirement from ^3.4.1 to ^3.4.3 in /superset-frontend/plugins/legacy-preset-chart-nvd3 (#40106)
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>
2026-05-26 06:04:20 +00:00
dependabot[bot]
1bc90a06ee chore(deps): bump serialize-javascript and terser-webpack-plugin in /superset-frontend/cypress-base (#40174)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
aa29c98ee9 chore(deps): bump minimatch from 3.1.3 to 3.1.5 in /superset-frontend/cypress-base (#40198)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
66a0c92c96 chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40154)
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>
2026-05-26 06:04:20 +00:00
Evan Rusackas
347f9fffad fix(date_parser): suppress noisy parsedatetime DEBUG logs (#33365) (#40144)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:20 +00:00
Evan Rusackas
3f71b283b0 fix(rls): align view permission name with REST API canonical name (#33744) (#40145)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
ec3525c0e8 chore(deps): bump minimatch from 3.1.2 to 3.1.5 in /superset-embedded-sdk (#40176)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
f1ce42c5b5 chore(deps): bump axios from 1.15.0 to 1.16.1 in /docs (#40177)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
d109a04e7c chore(deps-dev): bump @types/node from 25.7.0 to 25.8.0 in /superset-frontend (#40157)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
f83be777c4 chore(deps-dev): update sqlalchemy-exasol requirement from <3.0,>=2.4.0 to >=2.4.0,<8.0 (#40182)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
3a26426431 chore(deps): bump fast-uri from 3.0.6 to 3.1.2 in /superset-frontend (#40175)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
bf935d5541 chore(deps-dev): update clickhouse-connect requirement from <1.0,>=0.13.0 to >=0.13.0,<2.0 (#40184)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
02aa17bd68 chore(deps-dev): bump hdbcli from 2.4.162 to 2.28.20 (#40185)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
aa6f0c1ad3 chore(deps): bump flask-migrate from 3.1.0 to 4.1.0 (#40187)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
7d07ab790b chore(deps): bump greenlet from 3.1.1 to 3.5.0 (#40188)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
Evan Rusackas
b0cd86adb9 chore(docs): rename default docs plugin to user_docs for consistent versioned dir naming (#40171)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 06:04:20 +00:00
Elizabeth Thompson
5bba832131 fix(reports): narrow spinner checks to viewport and tighten exception handling (#39895)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:20 +00:00
Abdul Rehman
eccdbdd677 fix(i18n): correct Czech translation variables for SQL Lab query message (#40166)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
Beto Dealmeida
ee86d902d3 fix: OAuth2 exception should be 403 (#40074) 2026-05-26 06:04:20 +00:00
dependabot[bot]
83493ce39c chore(deps): bump zod from 4.4.1 to 4.4.3 in /superset-frontend (#40155)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
e579f90dc0 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40152)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
55e653cdcc chore(deps): bump @ant-design/icons from 6.2.2 to 6.2.3 in /superset-frontend (#40112)
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-26 06:04:20 +00:00
dependabot[bot]
87a52523a0 chore(deps): bump fast-xml-builder from 1.1.5 to 1.2.0 in /superset-frontend (#40103)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
cef55d4d4f chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#39821)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-26 06:04:20 +00:00
dependabot[bot]
c5a7f0e7ad chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#39699)
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-26 06:04:20 +00:00
dependabot[bot]
4ac50b3072 chore(deps-dev): bump @types/node from 25.7.0 to 25.8.0 in /superset-websocket (#40148)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
98d31d8d9b chore(deps): bump immer from 11.1.7 to 11.1.8 in /superset-frontend (#40158)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
82e801ee52 chore(deps): bump react-arborist from 3.5.0 to 3.6.1 in /superset-frontend (#40159)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
Richard Fogaca Nienkotter
239d452d5a fix(mcp): defer chart preview command imports (#40164) 2026-05-26 06:04:20 +00:00
dependabot[bot]
584925b68d chore(deps): bump antd from 6.3.7 to 6.4.2 in /docs (#40149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
30e67f6798 chore(deps-dev): bump webpack-dev-server from 5.2.3 to 5.2.4 in /superset-frontend (#40161)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
dependabot[bot]
4e16ac1ccb chore(deps-dev): bump tsx from 4.21.0 to 4.22.0 in /superset-frontend (#40162)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 06:04:20 +00:00
Shaitan
cc749b1723 fix: escape SQL identifiers in db engine spec prequeries and metadata queries (#39840)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:20 +00:00
Michael S. Molina
0e170e1387 fix(extensions): add cache headers and strip Vary: Cookie for extension static assets (#40120)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:20 +00:00
Shaitan
2fd22e8e98 fix(dataset): validate datasource access during import (#39998)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:20 +00:00
Shaitan
329f8e2400 fix(query): restrict query cancellation to the query owner (#39996)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:19 +00:00
Shaitan
1256e7a867 fix(database): extend shillelagh URI pattern to cover all driver variants (#39995)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:04:19 +00:00
kasiazjc
897a1d7d2c fix(listview): address kgabryje review — regressions, ARIA, cleanup
🔴 Regressions fixed:
- CompactSelectPanel: bump async fetch cap 50→200 (pagination workaround)
- CompactSelectPanel: accept panelStyle prop forwarded from filter popupStyle
- CardSortSelect: fix hardcoded hasValue=false; show active sort in pill label;
  onClear resets to default sort option
- index.tsx: remove double updateFilterValue on datetime/numerical_range clear
- index.tsx: tooltip cold-load fallback — resolve label from static selects
  when cache is empty (URL round-trip strips label from internalFilters)
- index.tsx: format unix timestamps in date tooltip as "MMM D, YYYY HH:mm"
  instead of raw ms numbers

🟡 Correctness / UX:
- CompactFilterTrigger: move × out of <button> into sibling ClearButton with
  aria-label="Clear {filter} filter" — fixes nested-interactive ARIA violation,
  adds proper tab stop, meets WCAG 2.5.5 target size (min 24×24)
- CompactFilterTrigger: replace cloneElement injection with render prop
  children: (props: { isOpen, onClose }) => ReactNode — removes single-child
  constraint and eliminates FilterPopoverContent's isOpen hack prop

🟢 Cleanup:
- CompactSelectPanel: extract SEARCH_THRESHOLD constant; remove redundant
  selectedOption local state (use value prop directly); add ArrowUp/ArrowDown
  key navigation between listbox options
- index.tsx: extract clearFilterAtIndex helper; eliminate duplicated clear logic
  in clearFilterById and select onClear
- FilterPopoverContent: remove unused isOpen? prop
2026-05-26 06:04:19 +00:00
kasiazjc
a28632c68b fix(tests): use selectPillOption in DashboardList filter tests
Old combobox selects replaced by compact pill triggers — tests were
still using selectOption (which looks for role=combobox). Switch to
the selectPillOption helper that interacts with the new pill UI.
2026-05-26 06:04:19 +00:00
kasiazjc
c1f0b180bc fix(listview): prettier format CompactFilterTrigger onOpenChange 2026-05-26 06:04:19 +00:00
kasiazjc
fca825625d fix(tests): update tests for compact filter pills UI changes
Update test selectors and expectations across ListView, ChartList,
DashboardList, DatasetList, UsersList, GroupsList, RolesList, and
RowLevelSecurityList tests to match the new compact pill filter UI.

Key changes:
- select-filter-container wrapper added back for backward compat
- card-sort-select data-test added back to CardSortSelect wrapper
- Multiple search filter test updated: only first search renders
- Tests using role=combobox updated to use new selectors
- Tests for hidden search labels updated
- Added selectPillOption helper to testing-library for compact pill UI
2026-05-26 06:04:19 +00:00
kasiazjc
673b135adc fix(listview): reset tooltipOpen when dropdown closes to prevent sticky tooltip 2026-05-26 06:04:19 +00:00
kasiazjc
51cec9c24f fix(listview): show tooltip whenever tooltipTitle is set, not just when hasValue 2026-05-26 06:04:19 +00:00
kasiazjc
65012bcad8 feat(listview): sort pill matches filter pills, placed before Clear all
Rewrite CardSortSelect to use CompactFilterTrigger + CompactSelectPanel —
same pill visual, same dropdown list, same hover behaviour as filter pills.
No label, checkmark on current selection, no clear icon (sort always has
a value). Move it before the Clear all button in the controls row.
2026-05-26 06:04:19 +00:00
kasiazjc
8dc9ae5930 fix(listview): one search box per page, fixed 176px width, CardSortSelect pinned right
- Only the first search filter renders; subsequent search filters on pages
  like UsersList (which have 5 search inputs) are skipped to keep one
  search box per page.
- Search container uses fixed width (sizeUnit*44 = 176px) with flex-shrink:0
  instead of flex:1, so it never resizes when a scrollbar appears or
  pills wrap (fixes Sophie's resize-on-scroll bug).
- CardSortSelect in card/thumbnail view gets margin-left:auto so it pins
  to the right edge of the controls row instead of orphaning on a new line.
2026-05-26 06:04:19 +00:00
kasiazjc
d6ced441de fix(listview): apply prettier formatting to pass CI pre-commit checks 2026-05-26 06:04:19 +00:00
kasiazjc
0dfd3e4045 fix(listview): remove default React import, fix prettier formatting 2026-05-26 06:04:19 +00:00
kasiazjc
3118daa63a docs: remove committed screenshots — use GitHub image upload for PR previews 2026-05-26 06:04:19 +00:00
kasiazjc
5e3419fe28 Revert "docs: remove screenshots from repo — should not be committed to codebase"
This reverts commit 064bdb45c23836c27a992dafc3f2b00b0ecce469.
2026-05-26 06:04:19 +00:00
kasiazjc
77779d7bda docs: remove screenshots from repo — should not be committed to codebase 2026-05-26 06:04:18 +00:00
kasiazjc
1ec5abc60e fix(listview): tooltip stays visible — control open state explicitly
Tooltip was receiving mouseLeave suppressed by the Dropdown overlay,
causing it to stick after the mouse moved away. Use controlled open
prop: tooltip is only shown when hasValue && !dropdown open && hover
is active. onOpenChange guards against showing when dropdown is open.
mouseLeaveDelay=0 ensures instant hide on mouse leave.
2026-05-26 06:04:18 +00:00
kasiazjc
94aa03bd3d fix(listview): fix search input clipping and match antd option height
Remove overflow:hidden from PanelContainer so the Input focus ring
is not clipped at the panel boundary.

Set OptionItem line-height to theme.lineHeight and derive the vertical
padding from antd's exact formula (controlHeight - fontSize * lineHeight) / 2,
matching the height of antd Select dropdown options in Explore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:55:46 +00:00
kasiazjc
ade1a0e5e2 fix(listview): close dropdown on resize; add OptionList padding
CompactFilterTrigger: close dropdown on window resize — AntD Dropdown
does not reposition on resize so the panel detaches from the pill.

CompactSelectPanel: add paddingXXS top/bottom to OptionList so the
first/last items have breathing room inside overflow:hidden when there
is no search row (small static selects), preventing outline/background
from being clipped against the panel edge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:52:04 +00:00
kasiazjc
a704330400 docs: retake screenshots — tooltip, dropdown, active state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:50:58 +00:00
kasiazjc
6c49ad74f9 fix(listview): remove duplicate top padding on panel container
PanelContainer paddingXXS (4px) top + SearchRow sizeUnit*2 (8px) top
= 12px gap. Remove PanelContainer top padding so SearchRow controls
the top spacing exclusively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:45:10 +00:00
kasiazjc
345d87b4b0 docs: update compact filter pills screenshots
New screenshots show: always-visible Clear all button (disabled when inactive),
Explore-style dropdown options, search input contained within panel, and
edge-to-edge option rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:44:30 +00:00
kasiazjc
d013003cf1 fix(listview): match search top padding to left/right for consistency
SearchRow top padding was 0 while left/right was sizeUnit*2 (8px).
Make them equal so the search input has uniform breathing room.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:44:03 +00:00
kasiazjc
75d731398e Revert "refactor(listview): CompactSelectPanel reuses Select/AsyncSelect"
This reverts commit 6a16e7dca4.
2026-05-20 14:42:05 +00:00
kasiazjc
6a16e7dca4 refactor(listview): CompactSelectPanel reuses Select/AsyncSelect
Replace custom option rendering, debounced search, race-condition
guards, and loading states with Select/AsyncSelect from
@superset-ui/core/components. The trigger is hidden via a
zero-height wrapper; the dropdown renders inside a container div
via getPopupContainer, giving the same visual as Explore's select
dropdowns. Also exposes the 'open' prop in AntdExposedProps so
Select/AsyncSelect can be controlled externally.

Removes ~130 lines of reimplemented debounce/race-condition/
filter logic and eliminates the outline hover hack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:41:18 +00:00
kasiazjc
eb945f8289 fix(listview): search input contained within dropdown panel
Wrap Input in SearchRow with horizontal padding instead of margins on
.ant-input-affix-wrapper. width:100% + margin overflowed the container;
padding on a wrapper div keeps the input fully inside. Restore
overflow:hidden (needed for borderRadiusLG clipping) — it no longer
clips the input since the wrapper contains it correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:38:30 +00:00
kasiazjc
96234d2cfe fix(listview): match Explore Select dropdown height + fix clear-all after refresh
CompactSelectPanel: remove overflow:hidden from panel (was clipping search
input); remove min-height from OptionItem (5px padding alone gives the
correct 24px height matching AntD Select optionPadding, not 42px).

index.tsx: clearFilters/clearFilterById now call updateFilterValue directly
as a safety net so URL always updates even when filter refs are stale
(e.g. after page refresh where internalFilters is URL-hydrated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:35:16 +00:00
kasiazjc
541cfd989c style(listview): match Select dropdown option style from Explore
Use exact visual pattern from @superset-ui/core Select:
- borderRadiusLG on container (was borderRadius)
- paddingXXS top/bottom on container (matches ant-select-dropdown)
- borderRadiusSM on each OptionItem (rounded highlight, not edge-to-edge)
- 5px vertical padding on items (matches optionPadding token default)
- outline: 2px solid colorPrimary on hover (Superset's own Select override)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:16:06 +00:00
kasiazjc
3c1f1d5535 feat(listview): tooltip on disabled Clear all button
Show 'No filters applied' tooltip on hover when the Clear all button
is disabled, so users understand why it is not interactive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:11:24 +00:00
kasiazjc
97c22497f4 fix(listview): search input grows to fill available space
Replace fixed 176px width on the search filter container with flex:1
so it expands into the space not taken by the pill filters.
min-width: 160px prevents it collapsing on narrow screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:10:11 +00:00
kasiazjc
f28a8f6f78 fix(listview): Clear all always visible, disabled when no active filters
Match Superset dashboard FilterBar pattern (isClearAllEnabled): always
render the button so users can discover it, just disable it when nothing
is selected. Prevents layout shift and builds muscle memory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:06:53 +00:00
kasiazjc
b868d3c7bf feat(listview): add Clear all button to compact filter controls
Show a 'Clear all' text button at the end of the filter pill row when
one or more filters are active. Disappears when all filters are inactive.
Matches dashboard native filter bar behaviour.

Also update ListView tests to use new compact-filter-pill buttons
instead of the old combobox queries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:06:23 +00:00
kasiazjc
765d9d39a9 docs: add compact filter pills screenshots for PR
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:48:11 +00:00
kasiazjc
d520d461b3 fix(listview): read tooltip label from DOM textContent, not ReactNode
Owner options have a styled ReactNode label — typeof checks fail and we
fell back to String(value) = "1". Instead, capture the rendered text via
e.currentTarget.textContent at click/keydown time (always the visible
string regardless of label type) and use it as the normalized label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:46:36 +00:00
kasiazjc
926b9b2311 feat(listview): compact filter pills for all CRUD list views
Replace full-width inline filter blocks with compact pill triggers across
all ListView CRUD views. Each non-search filter becomes a pill that opens
a focused dropdown on click. Async selects use a debounced options panel
matching the DrillBy submenu pattern. Date/range filters get an Apply
button. Active pills show colored border, dot indicator, hover tooltip
with selected value, and inline clear button.

- CompactFilterTrigger: pill + Ant Design Dropdown, injects isOpen/onClose
  into panel children via cloneElement; Tooltip on pill for active state;
  destroyPopupOnHide intentionally omitted to preserve filter refs
- CompactSelectPanel: options list with debounced async fetch, race-condition
  guard, keyboard nav (Enter/Space), proper ARIA (listbox/option/aria-selected,
  aria-label on listbox)
- FilterPopoverContent: Apply button wrapper for DateRange/NumericalRange;
  inner label visually hidden but screen-reader accessible
- index: expose clearFilterById in ref type to match ListView contract
- Tests: CompactSelectPanel.test.tsx and FilterPopoverContent.test.tsx added;
  datetime_range and numerical_range pill tests added to index.test.tsx;
  tooltip test in CompactFilterTrigger.test.tsx made non-brittle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:35:23 +00:00
kasiazjc
934443e09f fix(listview): cache tooltip label in UIFilters state to survive URL round-trip
URL serialization via use-query-params strips the label from the selected
SelectOption, leaving only {value: 1}. Reading the label back from
internalFilters after hydration therefore gives undefined.

Cache the string label in tooltipLabels state at the moment of selection
(before URL round-trip). Clear on onClear, clearFilters, and clearFilterById.
tooltipTitle now reads from the cache instead of internalFilters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:55:37 +00:00
kasiazjc
7d5b0e35e2 fix(listview): move Tooltip inside Dropdown so it fires on pill hover
Wrapping the Dropdown in an outer span+Tooltip caused Dropdown to absorb
mouse events before they reached the Tooltip. Move Tooltip to wrap
FilterPill directly inside the Dropdown — Ant Design supports Tooltip
as a Dropdown child and the pill receives hover events cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:48:30 +00:00
kasiazjc
6224bc7aec fix(listview): normalize select option + match AntD dropdown styles
Normalize selected option to plain {label: string, value} before passing
to onSelect — matches original SelectFilter behaviour and prevents
'Converting circular structure to JSON' when emotion-styled ReactNode
labels are serialized to URL query params.

Style OptionItem to match AntD Select dropdown exactly: colorBgElevated
container, fontSize (not fontSizeSM), colorText always (not colorPrimary
on selected), colorFillTertiary hover, borderRadiusSM on items, 32px
height with sizeUnit padding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:42:05 +00:00
kasiazjc
1b5a31a203 fix(listview): handle async fetchSelects rejection and null data
Add .catch() to the fetch effect so rejected promises (network errors,
auth failures) don't surface as unhandled rejections. Guard result.data
with ?? [] so null/undefined responses fall back to empty list. Filter
null entries from displayOptions before rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:54:58 +00:00
kasiazjc
7812f64278 fix(listview): remove ViewModeContainer margin-top that offset grid/list icons
The 21px margin-top on ViewModeContainer existed to visually align with
filter label text above each input. Labels are now hidden (pills have no
label), so the offset pushed the icons below the pill midline. Removing
it lets align-items: center on .header do the right thing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:46:43 +00:00
kasiazjc
12621e3e97 fix(listview): centre filter row with view-mode toggle icons
Add align-items: center to .header so the grid/list toggle and the
filter pills sit on the same midline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:42:51 +00:00
kasiazjc
81adabf667 refactor(listview): remove dead destructured props from compact filter index
optionFilterProps, paginate, and popupStyle were destructured in the filter
map but are no longer used after SelectFilter was replaced with
CompactSelectPanel. Remove them to eliminate dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:45:35 +00:00
kasiazjc
bff26e9256 refactor(listview): compact filter review fixes — type safety, a11y, race conditions
- index.tsx: remove redundant filterValue variable (merged into initialValue),
  remove no-op onClose={() => {}} from CompactSelectPanel (it's injected via
  cloneElement, the explicit pass was misleading), fix `any` type in
  clearFilters forEach, pass popupType="dialog" for datetime_range and
  numerical_range triggers
- CompactFilterTrigger: replace as Record<string,unknown> cloneElement cast
  with typed FilterPanelInjectedProps interface; add popupType prop (default
  'listbox', 'dialog' for form panels) wired to aria-haspopup so screen readers
  get the correct popup role per ARIA spec
- CompactSelectPanel: add cancelled flag to fetchSelects effect to prevent
  stale-closure state updates when deps change mid-flight; add
  debouncedSetSearch.cancel() cleanup on unmount; add role="listbox" to
  OptionList and role="option" + aria-selected + tabIndex + onKeyDown(Enter/
  Space) to OptionItem for keyboard navigation
- FilterPopoverContent: replace label { display: none } with visually-hidden
  CSS (position:absolute + clip) so FormLabel remains accessible to screen
  readers (datepicker/number inputs retain their accessible name context)
  while still hidden visually
- Tests: add 6 new tests covering isOpen/onClose injection via cloneElement,
  aria-haspopup default and dialog values, dropdown close aria-expanded reset,
  select filter pill rendering, and active state when value is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:44:23 +00:00
kasiazjc
8fa074d3a1 fix(listview): pin search filter container height to controlHeight
Force search-filter-container to controlHeight so it aligns flush with
the pill buttons (same height, align-items: center on the controls row
then centers everything correctly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:37:34 +00:00
kasiazjc
f984153936 fix(listview): compact filters UX audit — focus, date apply, alignment
Fix autoFocus stealing focus from search: inject isOpen via cloneElement,
use InputRef.input.focus pattern from DrillBySubmenu (100ms delay, preventScroll).
Debounce remote fetch triggers with Constants.FAST_DEBOUNCE. Reset panel
search on close.

Add FilterPopoverContent wrapper with Apply button for date/numerical range
filters so cloneElement-injected onClose closes the dropdown on submit.
Hides inner FormLabel via CSS since the pill already shows the label.

Match DrillBySubmenu styling: iconSize="l"/colorIcon for search prefix,
colorTextDisabled for empty state, min-height: 35px for option rows,
container padding replaces SearchRow border, .ant-input-affix-wrapper
margin-bottom for visual separation, min-width: 220px, box-shadow: none
on the input.

Pass external loading prop to CompactSelectPanel. Change .controls to
align-items: center and hide search-filter-container label via CSS.
Add aria-label to pill when label is a string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:09:02 +00:00
kasiazjc
c09295aa52 fix(listview): clear filter — keep dropdown mounted, direct state reset
Remove destroyPopupOnHide so CompactSelectPanel stays mounted when the
dropdown closes, keeping its ref live. Add updateFilterValue(index, undefined)
directly in all onClear handlers as a safety net so the ListView filter
state always resets even if the ref is stale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:52:29 +00:00
kasiazjc
002cb30a44 refine(listview): compact filters — match border radius/height, dropdown panel, hover tooltips
Use theme.borderRadius and theme.controlHeight on filter pills to match
the Search input and Select controls on the same row. Replace the
Popover+SelectFilter approach with a new CompactSelectPanel that renders a
clean options list (search + checkmark) opened via Dropdown — no
intermediate widget. Add tooltipTitle prop to CompactFilterTrigger so
hovering a filled pill shows the selected value. Align .controls with
flex-end so pills sit flush with the bottom of the search input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:50:42 +00:00
kasiazjc
6540353960 feat(listview): compact filter pills with popover for CRUD views
Replace full-width inline filter blocks with compact pill triggers that
open a Popover on click. Search filter stays inline. Adds active state
indicator (dot + border + weight) and inline clear button. Single new
component, no new deps. Controls gap tightened to 10px for pill rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:29:59 +00:00
216 changed files with 80870 additions and 16015 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

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

@@ -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,7 +101,7 @@ 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",
"typing-extensions>=4, <5",
@@ -137,7 +137,7 @@ 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"]
dynamodb = ["pydynamodb>=0.4.2"]
@@ -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

@@ -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,7 +415,7 @@ 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

View File

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

View File

@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
echo "$output" >&2
exit 1
}
[ -n "$output" ] && echo "$output"
if [ -n "$output" ]; then echo "$output"; fi
else
echo "No JavaScript/TypeScript files to lint"
fi

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": {
@@ -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": {

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

@@ -56,7 +56,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",

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

@@ -29,7 +29,7 @@
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.3"
"zod": "^4.4.1"
},
"peerDependencies": {
"@apache-superset/core": "*",

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

@@ -152,3 +152,33 @@ export async function selectOption(option: string, selectName?: string) {
);
await userEvent.click(item);
}
/**
* Select an option from a compact pill filter (new UI that replaced comboboxes).
* Clicks the pill button matching the label, then clicks the option in the panel.
*/
export async function selectPillOption(option: string, pillLabel?: string) {
let pill: HTMLElement;
if (pillLabel) {
// Find the pill whose text content includes the label
pill = await waitFor(() => {
const pills = screen.getAllByTestId('compact-filter-pill');
const match = pills.find(p => p.textContent?.includes(pillLabel));
if (!match)
throw new Error(`Could not find pill with label "${pillLabel}"`);
return match;
});
} else {
pill = await screen.findByTestId('compact-filter-pill');
}
await userEvent.click(pill);
// Wait for the option list to appear and click the item
const item = await waitFor(() => {
const listbox = document.querySelector('[role="listbox"]');
if (!listbox) throw new Error('No listbox found');
const opt = within(listbox as HTMLElement).getByText(option);
if (!opt) throw new Error(`Option "${option}" not found`);
return opt;
});
await userEvent.click(item);
}

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,14 @@ export function exploreJSON(
}),
),
);
(queriesResponse as QueryData[]).forEach(response => {
const { warning } = response as QueryData & {
warning?: string | null;
};
if (warning) {
dispatch(addWarningToast(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

@@ -16,20 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useMemo } from 'react';
import { useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { FormLabel, Select } from '@superset-ui/core/components';
import { SELECT_WIDTH } from './utils';
import type { SelectOption } from './types';
import { CardSortSelectOption, SortColumn } from './types';
const SortContainer = styled.div`
display: inline-flex;
font-size: ${({ theme }) => theme.fontSizeSM}px;
align-items: center;
text-align: left;
width: ${SELECT_WIDTH}px;
`;
import CompactFilterTrigger from './Filters/CompactFilterTrigger';
import CompactSelectPanel from './Filters/CompactSelectPanel';
import type { FilterHandler } from './Filters/types';
interface CardViewSelectSortProps {
onChange: (value: SortColumn[]) => void;
@@ -42,6 +35,8 @@ export const CardSortSelect = ({
onChange,
options,
}: CardViewSelectSortProps) => {
const panelRef = useRef<FilterHandler>(null);
const defaultSort =
(initialSort &&
options.find(
@@ -50,44 +45,57 @@ export const CardSortSelect = ({
)) ||
options[0];
const [value, setValue] = useState({
const [currentValue, setCurrentValue] = useState<SelectOption>({
label: defaultSort.label,
value: defaultSort.value,
});
const formattedOptions = useMemo(
() => options.map(option => ({ label: option.label, value: option.value })),
[options],
);
const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
const handleOnChange = (selected: { label: string; value: string }) => {
setValue(selected);
const originalOption = options.find(
({ value }) => value === selected.value,
);
if (originalOption) {
const sortBy = [
{
id: originalOption.id,
desc: originalOption.desc,
},
];
onChange(sortBy);
const isNonDefault = currentValue.value !== options[0]?.value;
const handleSelect = (option: SelectOption | undefined) => {
if (!option) return;
const original = options.find(o => o.value === option.value);
if (original) {
setCurrentValue({ label: original.label, value: original.value });
onChange([{ id: original.id, desc: original.desc }]);
}
};
const handleClear = () => {
const first = options[0];
if (first) {
setCurrentValue({ label: first.label, value: first.value });
onChange([{ id: first.id, desc: first.desc }]);
}
};
// Show the active sort value in the label so users can see the current sort
// without hovering — matches the previous inline-select UX.
const pillLabel = isNonDefault
? `${t('Sort')}: ${String(currentValue.label)}`
: t('Sort');
return (
<SortContainer>
<Select
ariaLabel={t('Sort')}
header={<FormLabel>{t('Sort')}</FormLabel>}
labelInValue
onChange={handleOnChange}
options={formattedOptions}
showSearch
value={value}
data-test="card-sort-select"
/>
</SortContainer>
<span data-test="card-sort-select">
<CompactFilterTrigger
label={pillLabel}
hasValue={isNonDefault}
onClear={handleClear}
tooltipTitle={isNonDefault ? String(currentValue.label) : undefined}
>
{({ isOpen, onClose }) => (
<CompactSelectPanel
ref={panelRef}
selects={selectOptions}
value={currentValue}
onSelect={handleSelect}
isOpen={isOpen}
onClose={onClose}
/>
)}
</CompactFilterTrigger>
</span>
);
};

View File

@@ -0,0 +1,151 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import CompactFilterTrigger from './CompactFilterTrigger';
// Base props without children — pass children as JSX to avoid no-children-prop lint rule.
const baseProps = {
label: 'Owner',
hasValue: false,
onClear: jest.fn(),
};
const defaultChildren = jest.fn(() => (
<div data-testid="filter-content">Filter content</div>
));
function renderTrigger(
props: Partial<
typeof baseProps & {
hasValue: boolean;
tooltipTitle?: string;
popupType?: 'listbox' | 'dialog';
}
> = {},
children = defaultChildren,
) {
return render(
<CompactFilterTrigger {...baseProps} {...props}>
{children}
</CompactFilterTrigger>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders the label', () => {
renderTrigger();
expect(screen.getByText('Owner')).toBeInTheDocument();
});
test('renders as inactive pill with down chevron when hasValue is false', () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
// No clear button when inactive
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
});
test('renders active state with clear button when hasValue is true', () => {
renderTrigger({ hasValue: true });
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
});
test('clear button has descriptive aria-label matching the filter name', () => {
renderTrigger({ hasValue: true });
const clearBtn = screen.getByTestId('compact-filter-clear');
expect(clearBtn).toHaveAttribute('aria-label', 'Clear Owner filter');
});
test('clear button is a separate element from the pill button', () => {
renderTrigger({ hasValue: true });
const pill = screen.getByTestId('compact-filter-pill');
const clearBtn = screen.getByTestId('compact-filter-clear');
// Buttons must not be nested
expect(pill).not.toContainElement(clearBtn);
expect(clearBtn).not.toContainElement(pill);
});
test('toggles aria-expanded when pill is clicked', async () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'true');
});
test('calls onClear when clear button is clicked', async () => {
const onClear = jest.fn();
renderTrigger({ hasValue: true, onClear } as any);
const clearBtn = screen.getByRole('button', { name: /clear owner filter/i });
await userEvent.click(clearBtn);
expect(onClear).toHaveBeenCalledTimes(1);
});
test('does not render tooltip wrapper when tooltipTitle is absent', () => {
const { container } = renderTrigger();
expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
});
test('shows active state indicators when hasValue and tooltipTitle are set', () => {
renderTrigger({ hasValue: true, tooltipTitle: 'Some Owner' });
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('calls children render prop with isOpen and onClose', async () => {
const children = jest.fn(() => <div data-testid="panel-content">panel</div>);
renderTrigger({}, children);
const pill = screen.getByTestId('compact-filter-pill');
await userEvent.click(pill);
expect(children).toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
);
});
test('sets aria-haspopup to listbox by default', () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
});
test('sets aria-haspopup to dialog when popupType is dialog', () => {
renderTrigger({ popupType: 'dialog' });
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
});
test('closing dropdown resets aria-expanded to false', async () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'false');
});

View File

@@ -0,0 +1,199 @@
/**
* 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 { useEffect, useState, type ReactNode, type MouseEvent } from 'react';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
export type FilterPanelRenderProps = {
isOpen: boolean;
onClose: () => void;
};
interface CompactFilterTriggerProps {
label: ReactNode;
hasValue: boolean;
onClear: () => void;
/** Render prop: receives { isOpen, onClose } and returns the panel content. */
children: (props: FilterPanelRenderProps) => ReactNode;
/** Shown as a hover tooltip when a value is selected (e.g. the selected label). */
tooltipTitle?: string;
/** ARIA popup role for the trigger button. Use 'listbox' for option panels,
* 'dialog' for form panels (date range, numerical range). */
popupType?: 'listbox' | 'dialog';
}
const TriggerWrapper = styled.span`
display: inline-flex;
align-items: center;
`;
const FilterPill = styled.button<{ $active: boolean }>`
${({ theme, $active }) => css`
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit}px;
height: ${theme.controlHeight}px;
padding: 0 ${theme.sizeUnit * 3}px;
border-radius: ${theme.borderRadius}px;
border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
color: ${$active ? theme.colorPrimary : theme.colorText};
font-size: ${theme.fontSizeSM}px;
font-weight: ${$active ? 600 : 400};
cursor: pointer;
white-space: nowrap;
transition:
border-color 0.2s,
background 0.2s,
color 0.2s;
&:hover {
border-color: ${theme.colorPrimary};
background: ${$active ? theme.colorPrimaryBgHover : theme.colorFillAlter};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimary};
outline-offset: 2px;
}
`}
`;
const ActiveDot = styled.span`
${({ theme }) => css`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${theme.colorPrimary};
flex-shrink: 0;
`}
`;
// Meets WCAG 2.5.5 target size (24×24 minimum) with explicit dimensions.
const ClearButton = styled.button`
${({ theme }) => css`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
margin-left: ${theme.sizeUnit / 2}px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: ${theme.colorPrimary};
border-radius: ${theme.borderRadiusSM}px;
&:hover {
background: ${theme.colorPrimaryBg};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimary};
outline-offset: 2px;
}
`}
`;
export default function CompactFilterTrigger({
label,
hasValue,
onClear,
children,
tooltipTitle,
popupType = 'listbox',
}: CompactFilterTriggerProps) {
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const theme = useTheme();
// Close dropdown on window resize — AntD Dropdown doesn't reposition
// itself on resize so the panel ends up detached from the pill.
useEffect(() => {
if (!open) return;
const handleResize = () => setOpen(false);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [open]);
const handleClear = (e: MouseEvent) => {
e.stopPropagation();
onClear();
setOpen(false);
};
const clearAriaLabel =
typeof label === 'string' ? `Clear ${label} filter` : 'Clear filter';
return (
<TriggerWrapper>
<Dropdown
open={open}
onOpenChange={visible => {
setOpen(visible);
if (!visible) setTooltipOpen(false);
}}
trigger={['click']}
popupRender={() =>
children({ isOpen: open, onClose: () => setOpen(false) })
}
placement="bottomLeft"
>
<Tooltip
title={tooltipTitle}
open={!!tooltipTitle && !open && tooltipOpen}
onOpenChange={visible =>
setTooltipOpen(visible && !!tooltipTitle && !open)
}
mouseEnterDelay={0.5}
mouseLeaveDelay={0}
>
<FilterPill
$active={hasValue}
type="button"
data-test="compact-filter-pill"
aria-haspopup={popupType}
aria-expanded={open}
aria-label={typeof label === 'string' ? label : undefined}
>
{hasValue && <ActiveDot />}
<span>{label}</span>
<Icons.DownOutlined
iconSize="xs"
iconColor={
hasValue ? theme.colorPrimary : theme.colorTextSecondary
}
/>
</FilterPill>
</Tooltip>
</Dropdown>
{hasValue && (
<ClearButton
type="button"
data-test="compact-filter-clear"
aria-label={clearAriaLabel}
onClick={handleClear}
>
<Icons.CloseOutlined iconSize="s" iconColor={theme.colorPrimary} />
</ClearButton>
)}
</TriggerWrapper>
);
}

View File

@@ -0,0 +1,339 @@
/**
* 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 { createRef, act } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import CompactSelectPanel from './CompactSelectPanel';
import type { FilterHandler } from './types';
const SMALL_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
];
const LARGE_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
{ label: 'David', value: 4 },
{ label: 'Eve', value: 5 },
{ label: 'Frank', value: 6 },
{ label: 'Grace', value: 7 },
];
beforeEach(() => {
jest.clearAllMocks();
});
test('renders options from selects prop', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
});
test('hides search input when selects.length is 6 or fewer', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
});
test('shows search input when selects.length exceeds 6', () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('shows search input when fetchSelects is provided', () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('filters static options by search term', async () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
});
test('calls onSelect with normalized option when an option is clicked', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('calls onSelect with undefined when same option is clicked twice (deselect)', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith(undefined, true);
});
test('shows checkmark icon on selected option', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const aliceOption = screen
.getByText('Alice')
.closest('[role="option"]') as HTMLElement;
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
});
test('unselected options have aria-selected false', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const bobOption = screen
.getByText('Bob')
.closest('[role="option"]') as HTMLElement;
expect(bobOption).toHaveAttribute('aria-selected', 'false');
});
test('calls onClose after a selection is made', async () => {
const onClose = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('clearFilter via ref resets selection and calls onSelect(undefined, true)', () => {
const onSelect = jest.fn();
const ref = createRef<FilterHandler>();
const { rerender } = render(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
act(() => {
ref.current?.clearFilter();
});
expect(onSelect).toHaveBeenCalledWith(undefined, true);
// Component is fully controlled — visual deselection follows when the
// parent passes value={undefined} after receiving the onSelect callback.
rerender(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('shows Loading text when loading prop is true', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
loading
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows No results when displayOptions is empty', () => {
render(
<CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
);
expect(screen.getByText('No results')).toBeInTheDocument();
});
test('renders options list with listbox role and accessible label', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const listbox = screen.getByRole('listbox');
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute('aria-label', 'Filter options');
});
test('option items have option role', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
test('fetches and displays remote options via fetchSelects on mount', async () => {
const fetchSelects = jest.fn().mockResolvedValue({
data: [{ label: 'Remote User', value: 99 }],
totalCount: 1,
});
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Remote User')).toBeInTheDocument();
});
expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
});
test('shows No results when fetchSelects returns empty data', async () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('shows No results when fetchSelects rejects', async () => {
const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('selects option via keyboard Enter key', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
await userEvent.type(aliceOption, '{Enter}');
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('syncs selected state when external value prop changes', () => {
const { rerender } = render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
rerender(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});

View File

@@ -0,0 +1,318 @@
/**
* 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 {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useState,
useEffect,
type CSSProperties,
type RefObject,
} from 'react';
import { debounce } from 'lodash';
import { t } from '@apache-superset/core/translation';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import {
Icons,
Input,
Constants,
type InputRef,
} from '@superset-ui/core/components';
import type { SelectOption, ListViewFilter as Filter } from '../types';
import type { FilterHandler } from './types';
// Show search box when there are more than this many static options.
const SEARCH_THRESHOLD = 6;
// Page size for async select fetches — large enough to avoid most pagination
// issues while still being a bounded request. Full infinite-load pagination
// is a future improvement.
const ASYNC_PAGE_SIZE = 200;
interface CompactSelectPanelProps {
selects?: Filter['selects'];
fetchSelects?: Filter['fetchSelects'];
value?: SelectOption;
onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
onClose?: () => void;
isOpen?: boolean;
/** Forwarded from the filter config's popupStyle for per-filter width overrides */
panelStyle?: CSSProperties;
/** External loading state from filter config */
loading?: boolean;
}
const PanelContainer = styled.div<{ $panelStyle?: CSSProperties }>`
${({ theme }) => css`
min-width: 220px;
max-width: 320px;
max-height: 320px;
display: flex;
flex-direction: column;
border-radius: ${theme.borderRadiusLG}px;
background: ${theme.colorBgElevated};
box-shadow: ${theme.boxShadowSecondary};
padding: 0 0 ${theme.paddingXXS}px;
`}
`;
const SearchRow = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px
${theme.paddingXXS}px;
`}
`;
const OptionList = styled.ul`
${({ theme }) => css`
margin: 0;
padding: ${theme.paddingXXS}px 0;
overflow-y: auto;
flex: 1;
list-style: none;
`}
`;
const OptionItem = styled.li<{ $active: boolean }>`
${({ theme, $active }) => css`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${(theme.controlHeight - theme.fontSize * theme.lineHeight) / 2}px
${theme.controlPaddingHorizontal}px;
line-height: ${theme.lineHeight};
cursor: pointer;
font-size: ${theme.fontSize}px;
color: ${theme.colorText};
border-radius: ${theme.borderRadiusSM}px;
background: ${$active ? theme.colorPrimaryBg : 'transparent'};
transition: background 0.15s;
&:hover {
background: ${$active
? theme.colorPrimaryBgHover
: theme.colorFillTertiary};
outline: 2px solid ${theme.colorPrimary};
outline-offset: -2px;
}
`}
`;
const OptionLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
`;
const StatusText = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
text-align: center;
color: ${theme.colorTextDisabled};
font-size: ${theme.fontSizeSM}px;
`}
`;
function CompactSelectPanel(
{
selects = [],
fetchSelects,
value,
onSelect,
onClose,
isOpen,
loading: externalLoading,
panelStyle,
}: CompactSelectPanelProps,
ref: RefObject<FilterHandler>,
) {
const theme = useTheme();
const inputRef = useRef<InputRef>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [remoteOptions, setRemoteOptions] = useState<SelectOption[]>([]);
const [internalLoading, setInternalLoading] = useState(false);
const isLoading = externalLoading || internalLoading;
const debouncedSetSearch = useMemo(
() => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
[],
);
useEffect(
() => () => {
debouncedSetSearch.cancel();
},
[debouncedSetSearch],
);
// Focus search input when dropdown opens; reset search when it closes
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (isOpen) {
timeoutId = setTimeout(() => {
inputRef.current?.input?.focus({ preventScroll: true });
}, 100);
} else {
setSearch('');
setDebouncedSearch('');
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [isOpen]);
// Fetch remote options when debounced search changes
useEffect(() => {
if (!fetchSelects) return;
let cancelled = false;
setInternalLoading(true);
fetchSelects(debouncedSearch, 0, ASYNC_PAGE_SIZE)
.then(result => {
if (!cancelled) setRemoteOptions(result?.data ?? []);
})
.catch(() => {
if (!cancelled) setRemoteOptions([]);
})
.finally(() => {
if (!cancelled) setInternalLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedSearch, fetchSelects]);
useImperativeHandle(ref, () => ({
clearFilter: () => {
setSearch('');
setDebouncedSearch('');
onSelect(undefined, true);
},
}));
const displayOptions = (
fetchSelects
? remoteOptions
: selects.filter(o => {
const label = typeof o.label === 'string' ? o.label : String(o.value);
return label.toLowerCase().includes(search.toLowerCase());
})
).filter(o => o != null);
const showSearch = !!fetchSelects || selects.length > SEARCH_THRESHOLD;
const handleSelect = (opt: SelectOption, displayText?: string) => {
const isDeselect = value?.value === opt.value;
// Normalize to a plain string label for URL serialization:
// 1. String labels pass through unchanged.
// 2. ReactNode labels with a `title` field use that (set by callers for
// options like owner-select where label contains name + email JSX).
// 3. Fall back to DOM text content, then stringified value.
const label =
typeof opt.label === 'string'
? opt.label
: (opt.title ?? displayText ?? String(opt.value ?? ''));
const next = isDeselect ? undefined : { label, value: opt.value };
onSelect(next, isDeselect);
onClose?.();
};
return (
<PanelContainer style={panelStyle}>
{showSearch && (
<SearchRow>
<Input
ref={inputRef}
prefix={
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
}
placeholder={t('Search')}
value={search}
onChange={e => {
setSearch(e.target.value);
debouncedSetSearch(e.target.value);
}}
allowClear
css={css`
width: 100%;
box-shadow: none;
`}
/>
</SearchRow>
)}
<OptionList role="listbox" aria-label={t('Filter options')}>
{isLoading ? (
<StatusText>{t('Loading...')}</StatusText>
) : displayOptions.length === 0 ? (
<StatusText>{t('No results')}</StatusText>
) : (
displayOptions.map((opt, i) => {
const isActive = value?.value === opt.value;
const getDisplayText = (el: HTMLElement) =>
el.textContent?.trim() || undefined;
const isFirst = i === 0;
const isLast = i === displayOptions.length - 1;
return (
<OptionItem
key={opt.value}
$active={isActive}
role="option"
aria-selected={isActive}
tabIndex={0}
onClick={e =>
handleSelect(opt, getDisplayText(e.currentTarget))
}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(opt, getDisplayText(e.currentTarget));
} else if (e.key === 'ArrowDown' && !isLast) {
e.preventDefault();
(
e.currentTarget.nextElementSibling as HTMLElement | null
)?.focus();
} else if (e.key === 'ArrowUp' && !isFirst) {
e.preventDefault();
(
e.currentTarget
.previousElementSibling as HTMLElement | null
)?.focus();
}
}}
>
<OptionLabel>{opt.label}</OptionLabel>
{isActive && (
<Icons.CheckOutlined
iconSize="s"
iconColor={theme.colorPrimary}
/>
)}
</OptionItem>
);
})
)}
</OptionList>
</PanelContainer>
);
}
export default forwardRef(CompactSelectPanel);

View File

@@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import FilterPopoverContent from './FilterPopoverContent';
beforeEach(() => {
jest.clearAllMocks();
});
test('renders children inside the wrapper', () => {
render(
<FilterPopoverContent>
<div data-test="inner-content">Inner content</div>
</FilterPopoverContent>,
);
expect(screen.getByTestId('inner-content')).toBeInTheDocument();
});
test('renders the Apply button', () => {
render(
<FilterPopoverContent>
<div>content</div>
</FilterPopoverContent>,
);
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('calls onClose when Apply button is clicked', async () => {
const onClose = jest.fn();
render(
<FilterPopoverContent onClose={onClose}>
<div>content</div>
</FilterPopoverContent>,
);
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('renders without onClose and clicking Apply does not throw', async () => {
render(
<FilterPopoverContent>
<div>content</div>
</FilterPopoverContent>,
);
// No onClose prop — click should not throw
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('visually hides label elements so pills remain accessible', () => {
render(
<FilterPopoverContent>
<label htmlFor="input">Date range</label>
<input id="input" />
</FilterPopoverContent>,
);
const label = screen.getByText('Date range');
// The label must be in the DOM for screen readers but visually hidden via CSS
expect(label).toBeInTheDocument();
const computedStyle = window.getComputedStyle(label);
// clip / overflow hidden pattern applied; position absolute is the key indicator
expect(computedStyle.position).toBe('absolute');
});

View File

@@ -0,0 +1,71 @@
/**
* 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 { ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, css } from '@apache-superset/core/theme';
import { Button } from '@superset-ui/core/components';
interface FilterPopoverContentProps {
children: ReactNode;
onClose?: () => void;
}
const Wrapper = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
/* Visually hide the redundant label — the pill already shows it, but keep it
accessible to screen readers so filter inputs have a named context. */
label {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`}
`;
const Footer = styled.div`
display: flex;
justify-content: flex-end;
`;
export default function FilterPopoverContent({
children,
onClose,
}: FilterPopoverContentProps) {
return (
<Wrapper>
{children}
<Footer>
<Button size="small" buttonStyle="primary" onClick={onClose}>
{t('Apply')}
</Button>
</Footer>
</Wrapper>
);
}

View File

@@ -16,11 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef } from 'react';
import { createRef, act } from 'react';
import {
render,
screen,
selectOption,
selectPillOption,
waitFor,
} from 'spec/helpers/testing-library';
import { ListViewFilterOperator } from '../types';
@@ -79,7 +79,7 @@ test('select filter with ReactNode label uses option title when serializing sele
/>,
);
await selectOption('John Doe', 'Owner');
await selectPillOption('John Doe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
@@ -120,7 +120,7 @@ test('select filter falls back to stringified value when no string label or titl
/>,
);
await selectOption('123', 'Something');
await selectPillOption('123', 'Something');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
@@ -156,7 +156,7 @@ test('plain select with string label passes label through unchanged', async () =
/>,
);
await selectOption('Published', 'Status');
await selectPillOption('Published', 'Status');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
@@ -197,7 +197,7 @@ test('plain select with ReactNode label uses option title when serializing selec
/>,
);
await selectOption('Jane Roe', 'Owner');
await selectPillOption('Jane Roe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
@@ -224,16 +224,18 @@ test('clearFilter notifies onSelect with undefined and isClear=true', () => {
/>,
);
ref.current?.clearFilter();
act(() => {
ref.current?.clearFilter();
});
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
});
test('rehydrates filter pill from initialValue with plain-string label', async () => {
// The user-visible regression: after URL/state rehydration the filter pill
// must render the human-readable name, not the numeric user id. The fix
// ensures the persisted label is a string; this test asserts that string
// is what surfaces in the rendered combobox selection.
// In the compact pill UI the rehydrated label is surfaced as the tooltip
// (visible on hover) rather than inline text. We verify the pill is in
// active state — the clear button is rendered — which confirms the
// SelectOption value object was correctly rehydrated.
const filters = [
{
Header: 'Owner',
@@ -262,6 +264,6 @@ test('rehydrates filter pill from initialValue with plain-string label', async (
);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
});
});

View File

@@ -97,7 +97,278 @@ test('search filter passes autoComplete prop correctly', () => {
expect(input.autocomplete).toBe('new-password');
});
test('renders multiple search filters with different inputName values', () => {
test('renders a compact pill trigger for select filters', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(screen.getByTestId('compact-filter-pill')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
});
test('select pill shows active state (clear button) when a value is selected', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [{ label: 'Alice', value: 1 }],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owner',
operator: ListViewFilterOperator.RelationOneMany,
value: { label: 'Alice', value: 1 },
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
});
test('select pill tooltip falls back to static selects on cold URL load (no cached label)', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
],
},
];
// Simulate cold URL load: value has only numeric value, no label in cache
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owner',
operator: ListViewFilterOperator.RelationOneMany,
value: { value: 1 } as any,
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// The pill should be active (clear button visible) and the static label
// should be resolved as the tooltip source
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
});
test('datetime_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
expect(screen.getByText('Time range')).toBeInTheDocument();
});
test('datetime_range pill shows active state when value is set', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: ['2024-01-01', '2024-12-31'],
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(
screen.getByRole('button', { name: /clear time range filter/i }),
).toBeInTheDocument();
});
test('datetime_range tooltip formats unix timestamps as human-readable dates', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
dateFilterValueType: 'unix' as const,
},
];
// Jan 1 2024 00:00:00 UTC in ms
const start = 1704067200000;
const end = 1735689599000;
const { container } = render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: [start, end],
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// Should NOT contain raw unix timestamp numbers in the rendered output
expect(container.textContent).not.toContain(String(start));
expect(container.textContent).not.toContain(String(end));
});
test('datetime_range tooltip leaves ISO strings as-is', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: ['2024-01-01T00:00:00.000Z', '2024-12-31T23:59:59.000Z'],
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// Pill is active (clear button present)
expect(
screen.getByRole('button', { name: /clear time range filter/i }),
).toBeInTheDocument();
});
test('numerical_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
expect(screen.getByText('Age range')).toBeInTheDocument();
});
test('numerical_range pill shows active state when value is set', () => {
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'age_range',
operator: ListViewFilterOperator.Between,
value: [18, 65],
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(
screen.getByRole('button', { name: /clear age range filter/i }),
).toBeInTheDocument();
});
test('renders only the first search filter when multiple search filters are configured', () => {
const filters = [
{
Header: 'Name',
@@ -125,8 +396,8 @@ test('renders multiple search filters with different inputName values', () => {
/>,
);
// Only the first search filter renders — one search box per page
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
expect(inputs).toHaveLength(2);
expect(inputs).toHaveLength(1);
expect(inputs[0].name).toBe('filter_name_search');
expect(inputs[1].name).toBe('description');
});

View File

@@ -19,11 +19,14 @@
import {
createRef,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useState,
RefObject,
} from 'react';
import { extendedDayjs } from '@superset-ui/core/utils/dates';
import { withTheme } from '@apache-superset/core/theme';
import type {
@@ -34,9 +37,11 @@ import type {
} from '../types';
import type { FilterHandler } from './types';
import SearchFilter from './Search';
import SelectFilter from './Select';
import DateRangeFilter from './DateRange';
import NumericalRangeFilter from './NumericalRange';
import CompactFilterTrigger from './CompactFilterTrigger';
import CompactSelectPanel from './CompactSelectPanel';
import FilterPopoverContent from './FilterPopoverContent';
interface UIFiltersProps {
filters: Filters;
@@ -46,7 +51,10 @@ interface UIFiltersProps {
function UIFilters(
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
ref: RefObject<{ clearFilters: () => void }>,
ref: RefObject<{
clearFilters: () => void;
clearFilterById: (id: string) => void;
}>,
) {
const filterRefs = useMemo(
() =>
@@ -54,20 +62,51 @@ function UIFilters(
[filters.length],
);
// Cache display labels for select filters so tooltip works after URL round-trip
// (URL serialization strips the label, leaving only the value).
const [tooltipLabels, setTooltipLabels] = useState<Record<number, string>>(
{},
);
const clearFilterAtIndex = useCallback(
(index: number) => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
setTooltipLabels(prev => {
const next = { ...prev };
delete next[index];
return next;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[updateFilterValue],
);
useImperativeHandle(ref, () => ({
clearFilters: () => {
filterRefs.forEach((filter: any) => {
filter.current?.clearFilter?.();
filterRefs.forEach((_, index) => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
});
setTooltipLabels({});
},
clearFilterById: (id: string) => {
const index = filters.findIndex(f => f.id === id);
if (index >= 0) {
filterRefs[index]?.current?.clearFilter?.();
clearFilterAtIndex(index);
}
},
}));
// Only the first search filter renders inline; subsequent ones are skipped
// to keep one search box per page (multi-field search pages like Users would
// otherwise show several input boxes in the header).
// NOTE: This means secondary search fields (e.g. Email/Username on Users,
// Group Key on RLS) are not currently accessible via the filter bar. Those
// pages previously relied on multiple inline inputs. This is a known UX
// trade-off — revisit if admin workflows require additional search fields.
let searchFilterRendered = false;
return (
<>
{filters.map(
@@ -78,8 +117,6 @@ function UIFilters(
key,
id,
input,
optionFilterProps,
paginate,
selects,
toolTipDescription,
onFilterUpdate,
@@ -87,44 +124,72 @@ function UIFilters(
dateFilterValueType,
min,
max,
popupStyle,
autoComplete,
inputName,
popupStyle,
},
index,
) => {
const initialValue = internalFilters?.[index]?.value;
if (input === 'select') {
const selectValue = initialValue as SelectOption | undefined;
// Prefer cached label (survives URL round-trips where only the value
// is preserved). Fall back to the static selects list for cold loads.
const cachedLabel = tooltipLabels[index];
const staticFallback = cachedLabel
? undefined
: selects?.find(s => s.value === selectValue?.value)?.label;
const tooltipTitle = !!selectValue
? cachedLabel ||
(typeof staticFallback === 'string'
? staticFallback
: undefined)
: undefined;
return (
<SelectFilter
ref={filterRefs[index]}
Header={Header}
fetchSelects={fetchSelects}
initialValue={initialValue}
key={key}
name={id}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
if (onFilterUpdate) {
// Filter change triggers both onChange AND onClear, only want to track onChange
if (!isClear) {
onFilterUpdate(option);
}
}
updateFilterValue(index, option);
}}
optionFilterProps={optionFilterProps}
paginate={paginate}
selects={selects}
loading={loading ?? false}
dropdownStyle={popupStyle}
/>
<span key={key} data-test="select-filter-container">
<CompactFilterTrigger
label={Header}
hasValue={!!selectValue}
tooltipTitle={tooltipTitle}
onClear={() => clearFilterAtIndex(index)}
>
{({ isOpen, onClose }) => (
<CompactSelectPanel
ref={filterRefs[index]}
selects={selects}
fetchSelects={fetchSelects}
value={initialValue as SelectOption | undefined}
loading={loading ?? false}
isOpen={isOpen}
onClose={onClose}
panelStyle={popupStyle}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
if (option && !isClear) {
setTooltipLabels(prev => ({
...prev,
[index]:
typeof option.label === 'string'
? option.label
: String(option.value ?? ''),
}));
}
if (onFilterUpdate && !isClear) {
onFilterUpdate(option);
}
updateFilterValue(index, option);
}}
/>
)}
</CompactFilterTrigger>
</span>
);
}
if (input === 'search' && typeof Header === 'string') {
if (searchFilterRendered) return null;
searchFilterRendered = true;
return (
<SearchFilter
ref={filterRefs[index]}
@@ -145,30 +210,81 @@ function UIFilters(
);
}
if (input === 'datetime_range') {
const hasDateValue =
Array.isArray(initialValue) && initialValue.some(Boolean);
const dateTooltip = hasDateValue
? (initialValue as (string | number)[])
.filter(Boolean)
.map(v => {
if (typeof v === 'number') {
// unix milliseconds → human-readable date
return extendedDayjs(v).format('MMM D, YYYY HH:mm');
}
// ISO string — already readable
return String(v);
})
.join(' ')
: undefined;
return (
<DateRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
<CompactFilterTrigger
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
dateFilterValueType={dateFilterValueType || 'unix'}
/>
label={Header}
hasValue={hasDateValue}
tooltipTitle={dateTooltip}
popupType="dialog"
onClear={() => {
filterRefs[index]?.current?.clearFilter?.();
}}
>
{({ onClose }) => (
<FilterPopoverContent onClose={onClose}>
<DateRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
name={id}
onSubmit={value => updateFilterValue(index, value)}
dateFilterValueType={dateFilterValueType || 'unix'}
/>
</FilterPopoverContent>
)}
</CompactFilterTrigger>
);
}
if (input === 'numerical_range') {
const hasRangeValue =
Array.isArray(initialValue) &&
initialValue.some(v => v !== null && v !== undefined);
const rangeTooltip = hasRangeValue
? (initialValue as (number | null | undefined)[])
.filter(v => v !== null && v !== undefined)
.join(' ')
: undefined;
return (
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
<CompactFilterTrigger
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
label={Header}
hasValue={hasRangeValue}
tooltipTitle={rangeTooltip}
popupType="dialog"
onClear={() => {
filterRefs[index]?.current?.clearFilter?.();
}}
>
{({ onClose }) => (
<FilterPopoverContent onClose={onClose}>
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
</FilterPopoverContent>
)}
</CompactFilterTrigger>
);
}
return null;

View File

@@ -301,15 +301,19 @@ describe('ListView', () => {
});
test('renders UI filters', () => {
const filterControls = screen.getAllByRole('combobox');
expect(filterControls).toHaveLength(2);
// select and datetime_range filters render as compact pill buttons;
// search filter renders as a text input
const filterPills = screen.getAllByTestId('compact-filter-pill');
expect(filterPills).toHaveLength(3); // ID, Age, Time
});
test('calls fetchData on filter', async () => {
// Handle select filter
const selectFilter = screen.getAllByRole('combobox')[0];
await userEvent.click(selectFilter);
const option = screen.getByText('foo');
// Click the ID compact pill to open its option panel
const idPill = screen.getByRole('button', { name: 'ID' });
await userEvent.click(idPill);
// Wait for and click the 'foo' option in the dropdown panel
const option = await screen.findByRole('option', { name: 'foo' });
await userEvent.click(option);
// Handle search filter
@@ -341,7 +345,10 @@ describe('ListView', () => {
initialSort: [{ id: 'something' }],
});
const sortSelect = screen.getByTestId('card-sort-select');
const sortSelectContainer = screen.getByTestId('card-sort-select');
const sortSelect = sortSelectContainer.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement;
await userEvent.click(sortSelect);
const sortOption = screen.getByText('Alphabetical');

View File

@@ -65,13 +65,30 @@ const ListViewStyles = styled.div`
.header {
display: flex;
align-items: center;
padding-bottom: ${theme.sizeUnit * 4}px;
& .controls {
display: flex;
flex-wrap: wrap;
column-gap: ${theme.sizeUnit * 7}px;
row-gap: ${theme.sizeUnit * 4}px;
align-items: center;
column-gap: ${theme.sizeUnit * 2}px;
row-gap: ${theme.sizeUnit * 2}px;
[data-test='search-filter-container'] {
width: ${theme.sizeUnit * 44}px;
flex-shrink: 0;
height: ${theme.controlHeight}px;
justify-content: center;
label {
display: none;
}
.ant-input-affix-wrapper {
width: 100%;
}
}
}
}
@@ -167,7 +184,6 @@ const bulkSelectColumnConfig = {
const ViewModeContainer = styled.div`
${({ theme }) => `
padding-right: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 5 + 1}px;
white-space: nowrap;
display: inline-block;
@@ -192,6 +208,29 @@ const ViewModeContainer = styled.div`
`}
`;
const ClearAllButton = styled.button`
${({ theme }) => `
background: none;
border: none;
padding: 0 ${theme.sizeUnit}px;
color: ${theme.colorPrimary};
font-size: ${theme.fontSizeSM}px;
cursor: pointer;
white-space: nowrap;
line-height: ${theme.controlHeight}px;
&:hover:not(:disabled) {
color: ${theme.colorPrimaryHover};
text-decoration: underline;
}
&:disabled {
color: ${theme.colorTextDisabled};
cursor: not-allowed;
}
`}
`;
const EmptyWrapper = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 40}px 0;
@@ -356,6 +395,14 @@ export function ListView<T extends object = any>({
clearFilterById: (id: string) => void;
}>(null);
const hasActiveFilters = internalFilters.some(f => {
if (f.value === null || f.value === undefined || f.value === '')
return false;
if (Array.isArray(f.value))
return f.value.some(v => v !== null && v !== undefined && v !== '');
return true;
});
// Wire the optional external filtersRef to our internal filterControlsRef.
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
// ref is populated before the first paint and after every update.
@@ -421,6 +468,21 @@ export function ListView<T extends object = any>({
options={cardSortSelectOptions}
/>
)}
{filterable && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
>
<span>
<ClearAllButton
type="button"
disabled={!hasActiveFilters}
onClick={() => filterControlsRef.current?.clearFilters()}
>
{t('Clear all')}
</ClearAllButton>
</span>
</Tooltip>
)}
</div>
</div>
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>

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

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

@@ -90,13 +90,7 @@ const defaultState = {
superset_can_explore: false,
superset_can_share: false,
superset_can_csv: false,
common: {
conf: {
SUPERSET_WEBSERVER_TIMEOUT: 0,
SQL_MAX_ROW: 666,
TABLE_VIZ_MAX_ROW_SERVER: 999,
},
},
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0, SQL_MAX_ROW: 666 } },
},
dashboardLayout: {
present: {},
@@ -207,7 +201,7 @@ test('should call exportChart when exportCSV is clicked', async () => {
stubbedExportCSV.mockRestore();
});
test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when exportFullCSV is clicked', async () => {
test('should call exportChart with row_limit props.maxRows when exportFullCSV is clicked', async () => {
(global as any).featureFlags = {
[FeatureFlag.AllowFullCsvExport]: true,
};
@@ -228,8 +222,7 @@ test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when expor
expect(stubbedExportCSV).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({
row_limit: 999,
full_export: true,
row_limit: 666,
dashboardId: 111,
}),
resultType: 'full',
@@ -263,7 +256,7 @@ test('should call exportChart when exportXLSX is clicked', async () => {
stubbedExportXLSX.mockRestore();
});
test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when exportFullXLSX is clicked', async () => {
test('should call exportChart with row_limit props.maxRows when exportFullXLSX is clicked', async () => {
(global as any).featureFlags = {
[FeatureFlag.AllowFullCsvExport]: true,
};
@@ -284,8 +277,7 @@ test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when expor
expect(stubbedExportXLSX).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({
row_limit: 999,
full_export: true,
row_limit: 666,
dashboardId: 111,
}),
resultType: 'full',

View File

@@ -224,10 +224,8 @@ const Chart = (props: ChartProps) => {
const emitCrossFilters = useSelector(
(state: RootState) => !!state.dashboardInfo.crossFiltersEnabled,
);
const fullExportMaxRows: number = useSelector(
(state: RootState) =>
(state.dashboardInfo.common.conf.TABLE_VIZ_MAX_ROW_SERVER as number) ||
(state.dashboardInfo.common.conf.SQL_MAX_ROW as number),
const maxRows: number = useSelector(
(state: RootState) => state.dashboardInfo.common.conf.SQL_MAX_ROW as number,
);
const streamingThreshold: number = useSelector(
(state: RootState) =>
@@ -482,7 +480,7 @@ const Chart = (props: ChartProps) => {
(formData as JsonObject).dashboardId = dashboardInfo.id;
const exportTable = useCallback(
(format: string, isFullExport: boolean, isPivot = false) => {
(format: string, isFullCSV: boolean, isPivot = false) => {
const logAction =
format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
@@ -492,11 +490,8 @@ const Chart = (props: ChartProps) => {
is_cached: isCached,
});
// For a "full" export, raise the requested row_limit and flag the
// request with full_export so the backend lifts the row-limit cap to
// TABLE_VIZ_MAX_ROW_SERVER (gated by the ALLOW_FULL_CSV_EXPORT flag).
const exportFormData = isFullExport
? { ...formData, row_limit: fullExportMaxRows, full_export: true }
const exportFormData = isFullCSV
? { ...formData, row_limit: maxRows }
: formData;
const resultType = isPivot ? 'post_processed' : 'full';
@@ -584,7 +579,7 @@ const Chart = (props: ChartProps) => {
sliceVizType,
isCached,
formData,
fullExportMaxRows,
maxRows,
dataMaskOwnState,
chartState,
props.id,

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