Compare commits

..

74 Commits

Author SHA1 Message Date
Joe Li
cbbf7e4cd4 test(ci): stabilize master checks 2026-07-01 18:11:29 -07:00
Evan Rusackas
748060d35e feat(i18n): backfill Thai (th) translations (AI-generated, needs review) (#41641)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:43:11 -07:00
Evan Rusackas
d57d69d3a6 feat(i18n): backfill Ukrainian (uk) translations (AI-generated, needs review) (#41645)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:42:58 -07:00
Evan Rusackas
706ff94f0b feat(i18n): backfill Slovak (sk) translations (AI-generated, needs review) (#41640)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:42:47 -07:00
Evan Rusackas
661d362580 feat(i18n): backfill new safe-link strings in de/lv/fi (AI-generated, needs review) (#41646)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:42:38 -07:00
Evan Rusackas
6bdcb7a83a feat(i18n): backfill Czech (cs) translations (AI-generated, needs review) (#41647)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:41:34 -07:00
Alejandro Solares
d1f7dd9c67 fix(deps): override fast-uri to 3.1.3 to fix CVE-2026-13676 (#41631)
Signed-off-by: Alejandro Solares <219859296+ASolarers-Rodriguez@users.noreply.github.com>
2026-07-01 17:28:45 -07:00
Evan Rusackas
c718f717cb feat(i18n): backfill Spanish (es) translations (AI-generated, needs review) (#41609)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:25:13 -07:00
innovark
b3197c9b5e fix(table-chart): fix "Search by" control visibility and improve table controls layout (#36073)
Co-authored-by: SBIN2010 <Sbin2010@mail.ru>
2026-07-01 17:22:36 -07:00
Mike Bridge
af0a55a4f3 feat(dashboards): soft-delete and restore (#40128)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-01 17:20:44 -07:00
Evan Rusackas
8be255de40 chore(i18n): harden backfill_po — full language-name map + resilient batch translation (#41644)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:28:19 -07:00
dependabot[bot]
f2e322c3c0 chore(deps-dev): bump sigstore from 4.1.0 to 4.1.1 in /superset-frontend (#41638)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 16:25:50 -07:00
Jean Massucatto
e58ce1cf39 fix(dashboard): pre-filter time grain for display controls (#40000)
Co-authored-by: Joe Li <joe@preset.io>
2026-07-01 16:10:52 -07:00
Mike Bridge
393adc4535 refactor(db): composite PK on M2M association tables (#39859)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-01 13:07:15 -07:00
Amin Ghadersohi
e0a3b1c10c fix(mcp): document select_columns valid fields and URL scheme for preview tools (#41595) 2026-07-01 15:58:12 -04:00
Evan Rusackas
6c57919647 chore(codeowners): update maintainer assignments (#41634)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-01 12:25:13 -07:00
Joe Li
bed1034c2f refactor(frontend): centralize subdirectory URL prefixing behind nav helpers (#39925)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan <evan@preset.io>
2026-07-01 11:20:13 -07:00
Mehmet Salih Yavuz
b7d5de8e52 fix(sqllab): truncate long tab names in the overflow ("...") dropdown (#41585) 2026-07-01 20:52:27 +03:00
Đỗ Trọng Hải
7d7c3ce723 feat(ci): install helm-docs directly instead of using whole brew setup (#41629) 2026-07-02 00:35:43 +07:00
Evan Rusackas
692f81d945 feat(i18n): backfill Finnish (fi) translations (AI-generated, needs review) (#41613)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:27:28 -07:00
Evan Rusackas
eeacd9b6dd feat(i18n): backfill Latvian (lv) translations (AI-generated, needs review) (#41612)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:27:07 -07:00
Evan Rusackas
b3c709b3d5 feat(i18n): backfill German (de) translations (AI-generated, needs review) (#41608)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:24:20 -07:00
Nitish Agarwal
792d677634 fix(reports): respect CSV_EXPORT sep and decimal config in email reports (#38616) 2026-07-01 10:23:48 -07:00
Evan Rusackas
2d2a72b721 fix(sqllab): show non-ASCII text in array/JSON columns instead of \uXXXX escapes (#41533)
Co-authored-by: Vladislav Korenkov <73882772+Quatters@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:22:11 -07:00
Evan Rusackas
55b2da75f6 fix(echarts): allow forcing categorical x-axis for temporal columns (#41221)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-01 10:21:59 -07:00
Amin Ghadersohi
7f4cac63c9 fix(mcp): escape LIKE wildcards in find_users to prevent user enumeration (#41593) 2026-07-01 13:01:46 -04:00
Amin Ghadersohi
da4cae1657 chore(deps): bump fastmcp from >=3.2.4 to >=3.4.2 (#41592) 2026-07-01 12:42:25 -04:00
Mehmet Salih Yavuz
438d4d569f test(sqllab): repair broken TablePreview and SavedQueryList jest tests (#41628) 2026-07-01 19:34:48 +03:00
Ale
6d2b94ceb8 fix(currency): derive default symbol position from locale when unset (#40931)
Co-authored-by: kleostouraiti <212892934+kleostouraiti@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:12:18 -07:00
Shaitan
2da2db6c7c feat(sql): schema-qualified table denylist + information_schema/lo_* defaults (#41120)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:57:45 +01:00
Shaitan
3651020014 fix(sql): cap parser input length via SQL_MAX_PARSE_LENGTH config (#40499)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: sha174n <pedro.sousa@preset.io>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-01 16:32:12 +01:00
Mike Bridge
b9e3f0aa1e feat(soft-delete): gate soft delete behind a temporary SOFT_DELETE release toggle (#41166)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 08:09:52 -07:00
dependabot[bot]
16e1f41cef chore(deps): bump swagger-ui-react from 5.32.7 to 5.32.8 in /docs (#41614)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 21:27:37 +07:00
Raphael Prudencio
7cc7e9f6e3 fix(async-query): prevent JWT InvalidSubjectError for guest users (#37862)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
2026-06-30 23:42:41 -07:00
Imamatdin
3e88b487b3 fix(point-cluster-map): guard invalid point radii (#40393)
Co-authored-by: Imamatdin <201577118+Imamatdin@users.noreply.github.com>
2026-06-30 22:48:43 -07:00
Evan Rusackas
ce9b9b0513 feat(i18n): add Japanese (ja) translations (AI-generated, needs review) (#41466)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: aikawa-ohno <aikawa-ohno@users.noreply.github.com>
2026-06-30 17:33:27 -07:00
Evan Rusackas
35194fe4d5 fix(i18n): key translation-regression check on per-msgid transitions (#41596)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:54:39 -07:00
Evan Rusackas
2a1f632daa fix(dashboard): surface size, limit, and config key in oversized dashboard error (#41532)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:10:01 -07:00
Amin Ghadersohi
fd9c84be43 feat(mcp): add get_dashboard_datasets tool (#40961) 2026-06-30 18:27:09 -04:00
Amin Ghadersohi
2bd9ab4c59 feat(mcp): add remove_chart_from_dashboard tool (#40958)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:16:08 -04:00
Amin Ghadersohi
bf88c62814 feat(mcp): add manage_native_filters tool (#40960)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 17:07:49 -04:00
dependabot[bot]
1e130feb80 chore(deps): bump marshmallow-union from 0.1.15 to 0.1.15.post1 (#41539)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-06-30 13:54:45 -07:00
dependabot[bot]
fd86eec889 chore(deps): bump echarts from 5.6.0 to 6.1.0 in /superset-frontend (#40264)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 13:54:11 -07:00
Evan Rusackas
a8f43890b1 chore(superset-ui-core): forward-compat fixes for TypeScript 6.0 (Phase B) (#39535)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 13:52:14 -07:00
Evan Rusackas
4bf203ee70 chore(config): default SMTP_SSL_SERVER_AUTH to True (#40647)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-30 13:50:38 -07:00
Jean Massucatto
805c12ef74 fix(dashboard): prevent double-click on create dashboard from creating duplicates (#40833) 2026-06-30 11:47:40 -07:00
Elizabeth Thompson
e15dc5735f fix(reports): pre-commit tab permalinks before state machine transaction (#41096)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:02:08 -07:00
Evan Rusackas
42a5f64256 chore(ci): silence zizmor adhoc-packages note for supersetbot install (#41546) 2026-07-01 00:43:32 +07:00
Amin Ghadersohi
c60d8bb656 feat(mcp): add tags + typed metadata fields to update_dashboard (#40957) 2026-06-30 10:36:31 -07:00
Amin Ghadersohi
c11fa206ce feat(mcp): add duplicate_dashboard tool (#40959)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:36:19 -07:00
dependabot[bot]
2b6806c090 chore(deps): bump js-yaml from 5.0.0 to 5.1.0 in /docs (#41566)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 10:35:46 -07:00
Đỗ Trọng Hải
95d688fb05 build(embedded-sdk): remove test files and related files from build artifact to be published (#41584) 2026-07-01 00:33:49 +07:00
Evan Rusackas
7de77a35bc chore(ci): pin @action-validator versions in GHA validator workflow (#41545)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:31:36 -07:00
Evan Rusackas
7245a092eb chore(ci): scope zizmor adhoc-packages on setup-supersetbot action (#41547)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:31:21 -07:00
dependabot[bot]
bbab644d12 chore(deps-dev): bump typescript-eslint from 8.61.1 to 8.62.0 in /docs (#41575)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 10:29:34 -07:00
Mallikarjuna Reddy Nimmakayala
a2b5fda661 fix(Table Chart): Show correct cache time moment for Query2 (#37482)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:21:31 -07:00
Evan Rusackas
ef4c6123b9 fix(sqllab): preserve whitespace in grid result cells (#41135)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-30 10:18:25 -07:00
Evan Rusackas
25f7b90761 fix(dashboard): remove stray focus outline on Filter Badge popover (closes #38789) (#41398)
Co-authored-by: Devin AI <devin-ai-integration[bot]@users.noreply.github.com>
2026-06-30 10:18:10 -07:00
Evan Rusackas
7827d43ea6 fix(embedded): show already-added allowed domains in embed modal (closes #35328) (#41399)
Co-authored-by: Devin AI <devin-ai-integration[bot]@users.noreply.github.com>
2026-06-30 10:17:53 -07:00
dependabot[bot]
92f48b0725 chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#41573)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 22:44:30 +07:00
dependabot[bot]
e0e1831d50 chore(deps): bump actions/setup-java from 5.3.0 to 5.4.0 (#41576)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 22:41:21 +07:00
dependabot[bot]
c4c531a855 chore(deps): bump actions/cache from 5.0.5 to 6.1.0 (#41572)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 22:40:51 +07:00
dependabot[bot]
2c47648588 chore(deps-dev): bump globals from 17.6.0 to 17.7.0 in /docs (#41571)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 22:40:19 +07:00
dependabot[bot]
56bd8ed0be chore(deps): bump azure/setup-helm from 5.0.0 to 5.0.1 (#41570)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 22:39:57 +07:00
yousoph
b8b23d6219 fix(bigquery): quote dotted STRUCT columns per-segment in drill to detail (#41462)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:45:31 -07:00
yousoph
105b896038 fix(explore): enable free-text entry for temporal D3 format selector in Table chart (#41194)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 22:38:50 -07:00
dependabot[bot]
a009fcec51 chore(deps-dev): bump @swc/core from 1.15.41 to 1.15.43 in /superset-frontend (#41543)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 22:00:40 -07:00
dependabot[bot]
59196fcac0 chore(deps): bump @swc/core from 1.15.41 to 1.15.43 in /docs (#41542)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 22:00:25 -07:00
dependabot[bot]
765927d681 chore(deps): bump js-yaml from 4.2.0 to 5.0.0 in /docs (#41520)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 21:59:08 -07:00
Evan Rusackas
3b82d2a170 fix(security): clean up stale can_import permission on ImportExportRestApi (#41309)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:38:18 -07:00
Mafi
4a32e0b8d1 fix(table): exclude metricSqlExpressions from ownState→extra_form_data spread (#41555)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 01:30:03 +02:00
Ville Brofeldt
ece8d8ffca fix(datasource): allow Gamma to load combined datasource list (#41553) 2026-06-29 15:08:29 -07:00
Elizabeth Thompson
ba9bd430cb fix(a11y): add aria-label to ActionButton span role=button (#41503) 2026-06-29 15:05:10 -07:00
Evan Rusackas
5c272f1315 chore(docs): tighten CSP and remove external widgets (#36685)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-29 14:57:06 -07:00
377 changed files with 31399 additions and 5710 deletions

14
.github/CODEOWNERS vendored
View File

@@ -1,7 +1,5 @@
# Notify all committers of DB migration changes, per SIP-59
# https://github.com/apache/superset/issues/13351
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe
# Notify some committers of changes in the components
@@ -12,28 +10,30 @@
# Notify Helm Chart maintainers about changes in it
/helm/superset/ @craig-rueda @dpgaspar @villebro @nytai @michael-s-molina @mistercrunch @rusackas @Antonio-RiveroMartnez
/helm/superset/ @dpgaspar @villebro @nytai @michael-s-molina @mistercrunch @rusackas @Antonio-RiveroMartnez @hainenber
# Notify E2E test maintainers of changes
/superset-frontend/cypress-base/ @sadpandajoe @geido @eschutho @rusackas @betodealmeida @mistercrunch
/superset-frontend/playwright/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch
/superset-frontend/cypress-base/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch
# Notify PMC members of changes to GitHub Actions
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @sha174n @dpgaspar @sadpandajoe @hainenber
# Notify PMC members of changes to CI-executed scripts (supply-chain risk:
# scripts/ files run directly in CI workflows and can execute arbitrary code)
/scripts/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber
/scripts/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @dpgaspar @sha174n @sadpandajoe @hainenber
# Notify PMC members of changes to required GitHub Actions
/.asf.yaml @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @Antonio-RiveroMartnez
/.asf.yaml @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @dpgaspar @sha174n @Antonio-RiveroMartnez
# Maps are a finicky contribution process we care about
**/*.geojson @villebro @rusackas
**/*.ipynb @villebro @rusackas
/superset-frontend/plugins/legacy-plugin-chart-country-map/ @villebro @rusackas
# Notify translation maintainers of changes to translations

View File

@@ -17,6 +17,7 @@ runs:
- name: Install supersetbot from npm
if: ${{ inputs.from-npm == 'true' }}
shell: bash
# zizmor: ignore[adhoc-packages] - supersetbot is a first-party Apache CLI (apache-superset/supersetbot) installed globally as a tool; a global CLI install has no application manifest/lockfile context
run: npm install -g supersetbot
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
@@ -31,6 +32,7 @@ runs:
if: ${{ inputs.from-npm == 'false' }}
shell: bash
working-directory: supersetbot
# zizmor: ignore[adhoc-packages] - installs the locally packed supersetbot tarball built from the trusted apache-superset/supersetbot checkout; no lockfile applies to a global CLI install
run: |
# simple trick to install globally with dependencies
npm pack

View File

@@ -40,7 +40,7 @@ jobs:
uses: ./.github/actions/setup-supersetbot/
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.10"

View File

@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: "temurin"
java-version: "11"

View File

@@ -37,7 +37,9 @@ jobs:
node-version: "20"
- name: Install Dependencies
run: npm install -g @action-validator/core @action-validator/cli --save-dev
# Versions are pinned to avoid ad-hoc, unpinned package installs
# (zizmor adhoc-packages). Bump deliberately when upgrading.
run: npm install -g @action-validator/core@0.6.0 @action-validator/cli@0.6.0
- name: Run Script
run: bash .github/workflows/github-action-validator.sh

View File

@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: "temurin"
java-version: "11"

View File

@@ -32,19 +32,18 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: ./.github/actions/setup-backend/
with:
python-version: ${{ matrix.python-version }}
- name: Enable brew and helm-docs
# Add brew to the path - see https://github.com/actions/runner-images/issues/6283
run: |
echo "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin" >> $GITHUB_PATH
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
echo "HOMEBREW_PREFIX=$HOMEBREW_PREFIX" >>"${GITHUB_ENV}"
echo "HOMEBREW_CELLAR=$HOMEBREW_CELLAR" >>"${GITHUB_ENV}"
echo "HOMEBREW_REPOSITORY=$HOMEBREW_REPOSITORY" >>"${GITHUB_ENV}"
brew install norwoodj/tap/helm-docs
- name: Setup Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
- name: Install helm-docs
run: go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.14.2
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
@@ -63,7 +62,7 @@ jobs:
yarn install --immutable
- name: Cache pre-commit environments
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}

View File

@@ -56,7 +56,7 @@ jobs:
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS
key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
@@ -70,7 +70,7 @@ jobs:
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: npm-cache # use this to check for `cache-hit` (`steps.npm-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.npm-cache-dir-path.outputs.dir }}

View File

@@ -71,7 +71,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: "zulu"
java-version: "21"

View File

@@ -26,7 +26,7 @@ jobs:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.16.4

View File

@@ -42,7 +42,7 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.5.4

View File

@@ -247,16 +247,13 @@ Understanding the Superset Points of View
- [Superset API](https://superset.apache.org/docs/rest-api)
## Repo Activity
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=39464018" target="_blank" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=dark" width="655" height="auto" />
<img alt="Performance Stats of apache/superset - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=light" width="655" height="auto" />
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
<!--
The OSS Insight "Repo Activity" widget (https://next.ossinsight.io/) was
intentionally removed. This README is rendered on the ASF-hosted website
(superset.apache.org), so its contents are subject to ASF's third-party
content and CSP rules. OSS Insight has no Data Processing Agreement (DPA)
with the ASF, so we cannot embed its images/widgets here. Do not re-add it.
-->
<!-- telemetry/analytics pixel: -->
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=bc1c90cd-bc04-4e11-8c7b-289fb2839492" />

View File

@@ -24,6 +24,45 @@ assists people when migrating to a new version.
## Next
- [39925](https://github.com/apache/superset/pull/39925): URL prefixing for `SUPERSET_APP_ROOT` subdirectory deployments is now handled automatically by helpers in `src/utils/navigationUtils` (`openInNewTab`, `redirect`, `getShareableUrl`, `<AppLink>`). Direct imports of `ensureAppRoot` / `makeUrl` from `src/utils/pathUtils` are forbidden outside `navigationUtils.ts` (enforced by a static-invariant test); contributors writing new code should use the focused helpers instead. No runtime behaviour change for existing callers — all 19 prior call sites have been migrated and four pre-existing double-prefix and missing-prefix bugs are fixed as part of the migration.
- [39925](https://github.com/apache/superset/pull/39925): `SupersetClient.getUrl()` now strips a single leading application-root segment from the supplied `endpoint` before building the request URL, so a caller that accidentally pre-prefixes its endpoint (for example by wrapping it with `ensureAppRoot` before passing it to the client) no longer produces a doubled `/superset/superset/...` URL under subdirectory deployment. The strip is **single-pass** — a genuine `/superset/superset/<slug>` route is preserved, not collapsed — and **silent** (no console warning); the static-invariant test remains the primary signal for pre-prefixing at the call site, and this runtime strip is a safety net beneath it. Code that intentionally targeted a literal `/<app_root>/<app_root>/...` endpoint through `getUrl` (a configuration that has no legitimate use under the prefixing model) would have its first redundant segment removed.
- **Breaking — `Superset` view class route prefix removed.** The `Superset` view in `superset/views/core.py` now declares `route_base = ""`, overriding Flask-AppBuilder's auto-derived `/superset` prefix. Routes that previously lived at `/superset/welcome/`, `/superset/dashboard/<id>/`, `/superset/dashboard/p/<key>/`, `/superset/explore/`, etc. now respond at `/welcome/`, `/dashboard/<id>/`, `/dashboard/p/<key>/`, `/explore/`, etc. Under subdirectory deployment (`SUPERSET_APP_ROOT=/superset`) the URLs are unchanged from end-user perspective — `AppRootMiddleware` re-applies the prefix via `SCRIPT_NAME`. Under root deployments, any external integration or bookmark that hard-codes `/superset/<endpoint>/` paths must be updated to drop the prefix. This fixes the doubled `/superset/superset/...` URLs that `url_for` emitted for these endpoints under subdirectory deployment and the related 404s on the routes themselves.
- **Breaking — Three sibling view classes route prefix removed.** Following the same rationale as the `Superset` class above, `ExplorePermalinkView` (`superset/views/explore.py`), `TagModelView`, and `TaggedObjectsModelView` (`superset/views/tags.py`, `superset/views/all_entities.py`) now mount at the application root rather than a hard-coded `/superset/...`. The user-visible URLs `/superset/explore/p/<key>/`, `/superset/tags/`, and `/superset/all_entities/` are unchanged under subdirectory deployment; under root deployments these views now serve `/explore/p/<key>/`, `/tags/`, and `/all_entities/`, so any external integration or bookmark must drop the `/superset/` prefix. `Dashboard.url` and `Dashboard.get_url` likewise return `/dashboard/<id>/` instead of the prior `/superset/dashboard/<id>/` literal so downstream consumers (DashboardList row hrefs, MCP service `dashboard_url`) emit a single, deployment-correct prefix.
- **Legacy `/superset/*` path support.** A new outermost WSGI middleware `LegacyPrefixRedirectMiddleware` (`superset/middleware/legacy_prefix_redirect.py`) 308-redirects every enumerated legacy `/superset/<canonical>` path to its post-`route_base=""` canonical location (e.g. `/superset/welcome/``/welcome/` under root; → `/superset/welcome/` under `SUPERSET_APP_ROOT=/superset`, because the canonical resolves through `AppRootMiddleware`). Bookmarks, email links, and external integrations survive the route-base collapse for one release cycle. POST against a GET-only canonical returns 410 Gone instead of 308 (308 would 405 on retry). The shim is removed at EOL `5.0.0`, matching the `@deprecated(eol_version="5.0.0")` gate on `Superset.explore` and `Superset.explore_json`.
- **PWA web app manifest served dynamically.** The PWA manifest is now served at `/pwa-manifest.json` (under `APPLICATION_ROOT`) by a new `PwaManifestView` (`superset/views/pwa_manifest.py`) instead of the static file at `/static/assets/pwa-manifest.json`. The legacy static source at `superset-frontend/src/pwa-manifest.json` has been removed (along with its `webpack.config.js` `CopyPlugin` rule). The new endpoint resolves `APPLICATION_ROOT` and `STATIC_ASSETS_PREFIX` at request time so PWA install works under subdirectory deployments and split static-prefix / app-root deployments (where `STATIC_ASSETS_PREFIX` points to a CDN host while the Superset backend stays under `APPLICATION_ROOT`). The `<link rel="manifest">` href in `superset/templates/superset/spa.html` was updated correspondingly (using a new `application_root_rstrip` template global). Operators with a forked `spa.html` should switch any manifest `<link>` to `{{ application_root_rstrip }}/pwa-manifest.json`.
- **Hard re-bookmark break — `/superset/sql/<database_id>/`.** SQL Lab moved to its own blueprint at `/sqllab/`. The legacy `/superset/sql/<id>/` shape changed to a query-string form (`/sqllab/?dbid=<id>`); no 1:1 path mapping exists, so `LegacyPrefixRedirectMiddleware` does **not** redirect this route — it passes through and surfaces a 404. Users with bookmarks to `/superset/sql/<id>/` must update them to `/sqllab/?dbid=<id>`.
- **`SqlaTable.sql_url` query-string format.** `SqlaTable.sql_url` now URL-encodes `table_name` and joins it as a query parameter rather than concatenating a second `?`. Previously, with `Database.sql_url` returning `/sqllab/?dbid=<id>`, the concatenation produced `/sqllab/?dbid=<id>?table_name=<raw>` — a malformed second `?` that broke the query parser. External code that parsed the legacy `<base>?table_name=<raw>` shape now sees properly percent-encoded values (e.g. `/``%2F`, ` ``+` or `%20`); decode with `urllib.parse.parse_qsl`.
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
### SQL Lab denies large-object and information_schema access by default
`DISALLOWED_SQL_FUNCTIONS` and `DISALLOWED_SQL_TABLES` now ship with additional default entries, so SQL Lab and chart-data queries that reference them are rejected where they were previously allowed:
- PostgreSQL large-object routines (`lo_from_bytea`, `lo_export`, `lo_import`, `lo_put`, `lo_create`, `lo_creat`, `lowrite`, `lo_get`, `loread`, `lo_unlink`), which read and write bytes on the database server's filesystem.
- The SQL-standard `information_schema` views (`tables`, `columns`, `routines`, `views`, the privilege/grant views, etc.), which expose table, column, privilege, and view-definition metadata across the whole database.
Deployments that legitimately query these (for example tooling that introspects `information_schema`) can restore the previous behavior by overriding `DISALLOWED_SQL_FUNCTIONS` / `DISALLOWED_SQL_TABLES` in `superset_config.py` to drop the entries they need.
Because the denylist now resolves the effective schema through the query-aware path, PostgreSQL queries that change the `search_path` (e.g. `SET search_path = ...`) are rejected on the SQL Lab execution and cost-estimate paths whenever any `DISALLOWED_SQL_TABLES` entry is configured (the default for PostgreSQL), matching the behavior previously applied only when `RLS_IN_SQLLAB` was enabled.
### SQL parser input length cap (SQL_MAX_PARSE_LENGTH)
The SQL parser now rejects scripts whose UTF-8 byte length exceeds the new
`SQL_MAX_PARSE_LENGTH` config option (default `1_000_000` bytes) before they are
handed to sqlglot, which bounds parser memory and CPU usage. A single query
larger than the cap (for example a very large `IN (...)` list or a big
virtual-dataset SQL) raises a parse error in SQL Lab and dashboard-generated
queries. Deployments that legitimately run queries above this size should raise
the value, and `SQL_MAX_PARSE_LENGTH = None` disables the check entirely.
### Guest-token RLS rules reject unknown fields
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
@@ -44,6 +83,10 @@ The git SHA and build number surfaced in the "About" section, the bootstrap payl
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.
### `FetchRetryOptions` callback parameters widened to allow `null`
The `error` and `response` parameters of the `retryDelay` and `retryOn` callbacks in `FetchRetryOptions` (exported from `@superset-ui/core`) are now typed `Error | null` and `Response | null` to match the actual call-site signature provided by `fetch-retry`. Because these parameter types are contravariant, consumers who typed their callbacks with the non-nullable `(attempt: number, error: Error, response: Response) => number` will get a TypeScript compile error. Widen your callback signatures to accept `Error | null` / `Response | null`.
### `thumbnail_url` removed from dashboard list API response
The `thumbnail_url` field has been removed from `GET /api/v1/dashboard/` list responses. External consumers relying on this field must now construct the thumbnail URL client-side using `id` and `changed_on_utc`:
@@ -77,6 +120,7 @@ Deployments that intentionally point webhooks at internal targets (chatops bridg
### Impala cancel_query blocks private/internal hosts by default
The Impala engine spec's `cancel_query` issues an HTTP request from the Superset backend to the host configured on the Impala database connection. That host is now validated before the request: if it resolves to a private/internal IP range, the cancel call is refused and a warning is logged. Operators whose Impala cluster runs on an internal network can opt out by setting `IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS = True` in `superset_config.py`. This mirrors the dataset-import and webhook opt-out flags.
### Map chart renderer and OpenStreetMap migration behavior
The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on
@@ -114,6 +158,11 @@ Operators can tune or disable the policy via config:
### Data uploads bounded by UPLOAD_MAX_FILE_SIZE_BYTES
Single data-file uploads (CSV, Excel, columnar) are now bounded by the `UPLOAD_MAX_FILE_SIZE_BYTES` config option, which defaults to `100 * 1024 * 1024` (100 MB). Files larger than this are rejected with a `413` before their contents are buffered into memory. Set `UPLOAD_MAX_FILE_SIZE_BYTES = None` to disable the check and restore unbounded uploads.
### Currency symbol position follows the locale when unset
When a chart's currency control leaves the **Prefix or suffix** field empty, the currency symbol position is now derived from the deployment locale's own convention via `Intl.NumberFormat` instead of always defaulting to a suffix. For example, under the default `en-US` locale `USD`, `GBP`, and `EUR` render as a prefix (`$ 1,000`), while eurozone locales such as `fr-FR` render `EUR` as a suffix (`1 000 €`). An explicit Prefix/Suffix selection is always honored and is unaffected.
Charts that relied on the previous always-suffix default for an unset position will render the symbol on the locale-appropriate side instead; set the position explicitly on the metric's currency control to pin it.
### Duration formatter precision
@@ -179,6 +228,18 @@ Runbook to adopt:
2. Set that value on the tunnel's `server_host_key` (via the database/SSH tunnel API or UI payload).
3. Optionally set `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING = True` in `superset_config.py` to require host-key verification on all tunnels.
### SMTP server certificate validation enabled by default
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
```python
SMTP_SSL_SERVER_AUTH = False
```
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
@@ -228,6 +289,28 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
### Soft delete and restore for dashboards
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If dashboards are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live dashboards in all lists and lookups (including slug lookups — if a soft-deleted dashboard's slug was reused while the flag was on, both rows become visible with the same slug). The `POST /<uuid>/restore` endpoint and the `dashboard_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
With the flag enabled: `DELETE /api/v1/dashboard/<id>` no longer hard-deletes the dashboard (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from the dashboard API's list, detail, and lookup endpoints, which return 404 for soft-deleted dashboards. The embedded-dashboard iframe URL (`/embedded/<uuid>`) keeps rendering because it reads only `embedded.allowed_domains` and `embedded.dashboard_id` (the FK column) without dereferencing the parent dashboard; the frontend's subsequent dashboard-API fetch is what sees the 404 and surfaces "dashboard not found" to the user.
**New endpoint** — `POST /api/v1/dashboard/<uuid>/restore` clears `deleted_at` and returns the dashboard to active state. Requires `can_write on Dashboard` and ownership of the row (or admin). Soft-deleted dashboards can also be surfaced in the list endpoint via the new `dashboard_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to dashboards they own — the same audience that can restore them.
**Permissions migration:** existing role grants of `can_write on Dashboard` cover the new restore endpoint automatically; no role migration is required.
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_dashboards_deleted_at`) to the `dashboards` table, and **replaces the full unique constraint on `slug`** with a partial unique index (`ix_dashboards_active_slug`) enforcing slug uniqueness only among active (non-soft-deleted) rows. The column add is instant. On Postgres the constraint swap briefly blocks reads and writes during `ALTER TABLE ... DROP CONSTRAINT` (acquires `ACCESS EXCLUSIVE`), then blocks writes only during `CREATE UNIQUE INDEX` (acquires `ShareLock`); reads pass through during the index build. Both windows are sub-second on a typical `dashboards` table. MySQL InnoDB builds the functional index online (no blocking).
**Rollback note:** the downgrade restores the original full unique constraint on `slug`. If the partial-index window allowed slug reuse (a soft-deleted row and an active row holding the same slug), `ALTER TABLE ... ADD CONSTRAINT idx_unique_slug UNIQUE (slug)` will abort with a unique-constraint violation. Before downgrading, hard-delete the soft-deleted duplicates (or rename one side) so each slug appears at most once across all rows. Rolling back the application code while leaving the new migration in place is also possible but exposes soft-deleted rows to the older code path; pair the rollback with a data decision (restore, hard-delete, or migrate-down).
The partial-index replacement is dialect-dependent: PostgreSQL uses a native `WHERE deleted_at IS NULL` partial index; MySQL 8.0.13+ uses a functional index over `(CASE WHEN deleted_at IS NULL THEN slug END)` (8.0.13 is the first release with functional key parts). **MySQL <8.0.13, MariaDB, and SQLite keep the original full unique constraint** (functional indexes / column-level UNIQUE recreation aren't supported cleanly — MariaDB is excluded even at 10.x because its `CASE`-expression index semantics differ), so on those backends a soft-deleted dashboard continues to reserve its slug for the lifetime of the row.
**Slug semantics:** on PostgreSQL and MySQL 8.0.13+, the slug of a soft-deleted dashboard is **free for reuse**. A new active dashboard can claim it immediately. Restoring a soft-deleted dashboard whose slug has since been claimed returns **422 with a clean error** (`DashboardSlugConflictError`) — rename one of the dashboards and retry; the restore is not silently rejected by a database-level constraint violation.
**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, role grants, owners, tags) — including role grants that were implicitly revoked by the deletion. Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and ownership of the deleted row (or admin) — non-owners get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
### Granular Export Controls
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
@@ -517,6 +600,29 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
}
```
### Composite primary keys on many-to-many association tables
Eight M:N association tables move from a synthetic `id INTEGER PRIMARY KEY` to a composite `PRIMARY KEY (fk1, fk2)` on their two foreign-key columns. The surrogate `id` is dropped, and the redundant `UNIQUE (fk1, fk2)` on the two tables that carried one is removed (now subsumed by the PK).
| Table | Composite PK |
|---|---|
| `dashboard_roles` | `(dashboard_id, role_id)` |
| `dashboard_slices` | `(dashboard_id, slice_id)` |
| `dashboard_user` | `(user_id, dashboard_id)` |
| `report_schedule_user` | `(user_id, report_schedule_id)` |
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
| `slice_user` | `(user_id, slice_id)` |
| `sqlatable_user` | `(user_id, table_id)` |
**Before upgrading:**
- The migration **deletes** two classes of pre-existing rows the composite PK cannot accommodate: duplicate `(fk1, fk2)` pairs (it keeps the lowest `id` and removes the rest) and rows with `NULL` in either FK column. Both are meaningless for `secondary=` association tables, but export the affected rows first if you need an audit record.
- External tooling (BI tools, backup scripts) that references the surrogate `id` on these tables will break; no application code references it.
- Downgrade restores the `id` column (and the original `UNIQUE` on the two tables that had it) but leaves the FK columns `NOT NULL` (intentional — a `NULL` FK in a junction row is meaningless).
For large `dashboard_slices` / `report_schedule_user` tables, see the operator runbook in [#39859](https://github.com/apache/superset/pull/39859) — pre-flight inventory queries, per-dialect lock-window sizing, and the duplicate / NULL-FK roll-up — to plan the maintenance window.
## 6.0.0
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.

View File

@@ -549,6 +549,24 @@ CELERY_BEAT_SCHEDULE = {
Adjust `retention_period_days` to control how long query rows are kept. Companion opt-in tasks (`prune_logs`, `prune_tasks`) exist for pruning the logs and tasks tables; see the commented-out examples in `superset/config.py`. Without enabling these tasks, the metadata database will grow unbounded over time.
## Dashboard Layout Size Limit
Each dashboard stores its layout (the position, size, and nesting of every chart, row, and tab) as a JSON blob in the metadata database. Superset caps the length of this serialized blob with `SUPERSET_DASHBOARD_POSITION_DATA_LIMIT`, which defaults to `65535`:
```python
SUPERSET_DASHBOARD_POSITION_DATA_LIMIT = 65535
```
This is a Python-level cap (65535 is 2¹⁶ 1), independent of the database column capacity — the `position_json` column is a `MEDIUMTEXT`, which holds far more. When the serialized layout reaches this limit, the editor blocks the save and reports the current length, the limit, and this setting's name. A warning is shown once the layout passes 90% of the limit.
Large dashboards — for example, many charts spread across nested tabs — can exceed the default. Because the underlying column comfortably stores larger values, you can safely raise the limit:
```python
SUPERSET_DASHBOARD_POSITION_DATA_LIMIT = 131072 # double the default
```
Alternatively, split a very large dashboard into several smaller ones. Note that this check is enforced when saving layout edits in the UI; a dashboard imported from a ZIP with an oversized layout will load and render, but cannot be edited and re-saved until the limit is raised.
:::resources
- [Blog: Feature Flags in Apache Superset](https://preset.io/blog/feature-flags-in-apache-superset-and-preset/)
:::

View File

@@ -254,16 +254,13 @@ Understanding the Superset Points of View
- [Superset API](/developer-docs/api)
## Repo Activity
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=39464018" target="_blank" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=dark" width="655" height="auto" />
<img alt="Performance Stats of apache/superset - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=light" width="655" height="auto" />
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
<!--
The OSS Insight "Repo Activity" widget (https://next.ossinsight.io/) was
intentionally removed. This page is rendered on the ASF-hosted website
(superset.apache.org), so its contents are subject to ASF's third-party
content and CSP rules. OSS Insight has no Data Processing Agreement (DPA)
with the ASF, so we cannot embed its images/widgets here. Do not re-add it.
-->
<!-- telemetry/analytics pixel: -->
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=bc1c90cd-bc04-4e11-8c7b-289fb2839492" />

View File

@@ -70,13 +70,13 @@
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.41",
"@swc/core": "^1.15.43",
"antd": "^6.4.5",
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
"js-yaml": "^4.2.0",
"js-yaml": "^5.1.0",
"js-yaml-loader": "^1.2.2",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
@@ -89,7 +89,7 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^8.6.18",
"swagger-ui-react": "^5.32.7",
"swagger-ui-react": "^5.32.8",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
@@ -106,10 +106,10 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.6.0",
"globals": "^17.7.0",
"prettier": "^3.8.4",
"typescript": "~6.0.3",
"typescript-eslint": "^8.61.1",
"typescript-eslint": "^8.62.0",
"webpack": "^5.107.2"
},
"browserslist": {

View File

@@ -22,7 +22,14 @@ RewriteRule ^(.*)$ https://superset.apache.org/$1 [R,L]
RewriteCond %{HTTP_HOST} ^superset.incubator.apache.org$ [NC]
RewriteRule ^(.*)$ https://superset.apache.org/$1 [R=301,L]
Header set Content-Security-Policy "default-src data: blob: 'self' *.apache.org widget.kapa.ai *.githubusercontent.com *.scarf.sh *.googleapis.com *.google.com *.run.app *.gstatic.com *.github.com *.algolia.net *.algolianet.com 'unsafe-inline' 'unsafe-eval'; frame-src *; frame-ancestors 'self' *.google.com https://sidebar.bugherd.com; form-action 'self'; worker-src blob:; img-src 'self' blob: data: https:; font-src 'self'; object-src 'none'"
# CSP permissions for superset.apache.org
# Additional domains required for docs site functionality:
# - widget.kapa.ai: AI chatbot widget (uses Google reCAPTCHA). Approval here: https://privacy.apache.org/faq/committers.html
# - *.googleapis.com, *.google.com, *.gstatic.com: Google Calendar embed, kapa.ai reCAPTCHA - all of these loaded with user consent, following policy laid out in https://privacy.apache.org/faq/committers.html
# - github.com, *.github.com, *.githubusercontent.com: GitHub user-attachment images in docs (apex github.com serves user-attachments/* assets). Discussed/resolved in this thread: https://issues.apache.org/jira/browse/INFRA-25701?filter=-2 (DPA in place with GitHub)
# - *.algolia.net, *.algolianet.com: Algolia DocSearch. Approved here: https://privacy.apache.org/faq/committers.html
# See: https://infra.apache.org/tools/csp.html
SetEnv CSP_PROJECT_DOMAINS "widget.kapa.ai https://*.googleapis.com/ https://*.google.com/ https://*.gstatic.com/ https://github.com/ https://*.github.com/ https://*.githubusercontent.com/ https://*.algolia.net/ https://*.algolianet.com/"
# REDIRECTS

View File

@@ -87,6 +87,12 @@
"lifecycle": "development",
"description": "Enable semantic layers and show semantic views alongside datasets"
},
{
"name": "SOFT_DELETE",
"default": false,
"lifecycle": "development",
"description": "Temporary rollout / kill-switch gate for soft delete (default off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once soft delete is stable."
},
{
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
"default": false,

View File

@@ -212,16 +212,13 @@ Understanding the Superset Points of View
- [Superset API](https://superset.apache.org/docs/rest-api)
## Repo Activity
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=39464018" target="_blank" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=dark" width="655" height="auto" />
<img alt="Performance Stats of apache/superset - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=light" width="655" height="auto" />
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
<!--
The OSS Insight "Repo Activity" widget (https://next.ossinsight.io/) was
intentionally removed. This page is rendered on the ASF-hosted website
(superset.apache.org), so its contents are subject to ASF's third-party
content and CSP rules. OSS Insight has no Data Processing Agreement (DPA)
with the ASF, so we cannot embed its images/widgets here. Do not re-add it.
-->
<!-- telemetry/analytics pixel: -->
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=bc1c90cd-bc04-4e11-8c7b-289fb2839492" />

View File

@@ -254,16 +254,13 @@ Understanding the Superset Points of View
- [Superset API](/developer-docs/api)
## Repo Activity
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=39464018" target="_blank" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=dark" width="655" height="auto" />
<img alt="Performance Stats of apache/superset - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=light" width="655" height="auto" />
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
<!--
The OSS Insight "Repo Activity" widget (https://next.ossinsight.io/) was
intentionally removed. This page is rendered on the ASF-hosted website
(superset.apache.org), so its contents are subject to ASF's third-party
content and CSP rules. OSS Insight has no Data Processing Agreement (DPA)
with the ASF, so we cannot embed its images/widgets here. Do not re-add it.
-->
<!-- telemetry/analytics pixel: -->
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=bc1c90cd-bc04-4e11-8c7b-289fb2839492" />

View File

@@ -246,16 +246,13 @@ Understanding the Superset Points of View
- [Superset API](https://superset.apache.org/docs/rest-api)
## Repo Activity
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=39464018" target="_blank" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=dark" width="655" height="auto" />
<img alt="Performance Stats of apache/superset - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=39464018&image_size=auto&color_scheme=light" width="655" height="auto" />
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
<!--
The OSS Insight "Repo Activity" widget (https://next.ossinsight.io/) was
intentionally removed. This page is rendered on the ASF-hosted website
(superset.apache.org), so its contents are subject to ASF's third-party
content and CSP rules. OSS Insight has no Data Processing Agreement (DPA)
with the ASF, so we cannot embed its images/widgets here. Do not re-add it.
-->
<!-- telemetry/analytics pixel: -->
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=bc1c90cd-bc04-4e11-8c7b-289fb2839492" />

View File

@@ -4153,86 +4153,86 @@
dependencies:
apg-lite "^1.0.4"
"@swc/core-darwin-arm64@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz#4fcbc9cbb9dfc9027d66e2b23b8d1d0315d164bd"
integrity sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==
"@swc/core-darwin-arm64@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz#386294f8427dde2df1a70dd0a5826d67af70e996"
integrity sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==
"@swc/core-darwin-x64@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz#726c60a893e2f1a07bee28f79b519b8e6489415b"
integrity sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==
"@swc/core-darwin-x64@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz#c4823529c424e2ae25b7eb786438474741521fcb"
integrity sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==
"@swc/core-linux-arm-gnueabihf@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz#08930e8015ca2fadc729546d5bd4b758a3999dda"
integrity sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==
"@swc/core-linux-arm-gnueabihf@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz#c0a0ed17cffc5d4af192935667f12f05feeb39f9"
integrity sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==
"@swc/core-linux-arm64-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz#6c27490a4013647a09ff64cea1d6b1169394602f"
integrity sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==
"@swc/core-linux-arm64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz#1eb2d9c5eeee5bb9d00599b475ddc31dc2870d22"
integrity sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==
"@swc/core-linux-arm64-musl@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz#4cce52fbbbe78b1f99c2a4e3f9ad2629f6eae494"
integrity sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==
"@swc/core-linux-arm64-musl@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz#ea6b5c38088f3921a57922d3931b2d74fd23a9fd"
integrity sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==
"@swc/core-linux-ppc64-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz#3d1fadd8d320e7250a6b2a2d9c0b0d4dac162f97"
integrity sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==
"@swc/core-linux-ppc64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz#538fac30bbd5f1e678bb7bac9ccc62246a6f6d7a"
integrity sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==
"@swc/core-linux-s390x-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz#6e4c54168d4a8d7852ef797437bd25e6fb5d7a50"
integrity sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==
"@swc/core-linux-s390x-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz#ee564b45f3f578b1fc82136c4dab163189316641"
integrity sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==
"@swc/core-linux-x64-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz#5f947698786e15e2f696e0c6b3afd25138bae86b"
integrity sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==
"@swc/core-linux-x64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz#e6e3bfea76921c7f5e16d50a126615f2e04ce1c8"
integrity sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==
"@swc/core-linux-x64-musl@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz#f4a0910cb273e39bcc09d572a08f62a355a93628"
integrity sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==
"@swc/core-linux-x64-musl@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz#539f6f2721c0cc32e5db5cf0d453c82045f6662d"
integrity sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==
"@swc/core-win32-arm64-msvc@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz#a55334b1b7c23a962d4219f332b6422f3c3374e4"
integrity sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==
"@swc/core-win32-arm64-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz#b7bb6b611d484ac19d0ee21469e7012d646c28b5"
integrity sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==
"@swc/core-win32-ia32-msvc@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz#e1135f8d6857f6c48e4bfb6105568b37b3f88dc5"
integrity sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==
"@swc/core-win32-ia32-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz#e5b25722a7d27bb0c9a9bdee7863f29c8674364e"
integrity sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==
"@swc/core-win32-x64-msvc@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz#52d241e2bf4c6154675c0ad447b29cbdb0ccb547"
integrity sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==
"@swc/core-win32-x64-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz#d28842621201c345383d468d40c09648b6cd6e68"
integrity sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==
"@swc/core@^1.15.41", "@swc/core@^1.7.39":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.41.tgz#a212c5040abd1ffd2ad6caf140f0d586ffcfaa6e"
integrity sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==
"@swc/core@^1.15.43", "@swc/core@^1.7.39":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.43.tgz#653e6573968fd5c74163b9885ea0a933012c9f22"
integrity sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.26"
"@swc/types" "^0.1.27"
optionalDependencies:
"@swc/core-darwin-arm64" "1.15.41"
"@swc/core-darwin-x64" "1.15.41"
"@swc/core-linux-arm-gnueabihf" "1.15.41"
"@swc/core-linux-arm64-gnu" "1.15.41"
"@swc/core-linux-arm64-musl" "1.15.41"
"@swc/core-linux-ppc64-gnu" "1.15.41"
"@swc/core-linux-s390x-gnu" "1.15.41"
"@swc/core-linux-x64-gnu" "1.15.41"
"@swc/core-linux-x64-musl" "1.15.41"
"@swc/core-win32-arm64-msvc" "1.15.41"
"@swc/core-win32-ia32-msvc" "1.15.41"
"@swc/core-win32-x64-msvc" "1.15.41"
"@swc/core-darwin-arm64" "1.15.43"
"@swc/core-darwin-x64" "1.15.43"
"@swc/core-linux-arm-gnueabihf" "1.15.43"
"@swc/core-linux-arm64-gnu" "1.15.43"
"@swc/core-linux-arm64-musl" "1.15.43"
"@swc/core-linux-ppc64-gnu" "1.15.43"
"@swc/core-linux-s390x-gnu" "1.15.43"
"@swc/core-linux-x64-gnu" "1.15.43"
"@swc/core-linux-x64-musl" "1.15.43"
"@swc/core-win32-arm64-msvc" "1.15.43"
"@swc/core-win32-ia32-msvc" "1.15.43"
"@swc/core-win32-x64-msvc" "1.15.43"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -4307,10 +4307,10 @@
"@swc/html-win32-ia32-msvc" "1.15.13"
"@swc/html-win32-x64-msvc" "1.15.13"
"@swc/types@^0.1.26":
version "0.1.26"
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.26.tgz#2a976a1870caef1992316dda1464150ee36968b5"
integrity sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==
"@swc/types@^0.1.27":
version "0.1.27"
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.27.tgz#12080b0c426dea450634f202d9a3c82ac396e793"
integrity sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==
dependencies:
"@swc/counter" "^0.1.3"
@@ -4932,110 +4932,110 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.61.1", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz#6e4b7fee21f1983308e9e9b634ecbaf702c86006"
integrity sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==
"@typescript-eslint/eslint-plugin@8.62.0", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz#ef482aab65b9b2c0abf92d36d670a0d270bcef4c"
integrity sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.61.1"
"@typescript-eslint/type-utils" "8.61.1"
"@typescript-eslint/utils" "8.61.1"
"@typescript-eslint/visitor-keys" "8.61.1"
"@typescript-eslint/scope-manager" "8.62.0"
"@typescript-eslint/type-utils" "8.62.0"
"@typescript-eslint/utils" "8.62.0"
"@typescript-eslint/visitor-keys" "8.62.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.61.1", "@typescript-eslint/parser@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.1.tgz#881fba60b50636249cdeea2e547bf75715254c72"
integrity sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==
"@typescript-eslint/parser@8.62.0", "@typescript-eslint/parser@^8.61.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.62.0.tgz#8533094fb44427f50b82813c6d3876782f20dc3e"
integrity sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==
dependencies:
"@typescript-eslint/scope-manager" "8.61.1"
"@typescript-eslint/types" "8.61.1"
"@typescript-eslint/typescript-estree" "8.61.1"
"@typescript-eslint/visitor-keys" "8.61.1"
"@typescript-eslint/scope-manager" "8.62.0"
"@typescript-eslint/types" "8.62.0"
"@typescript-eslint/typescript-estree" "8.62.0"
"@typescript-eslint/visitor-keys" "8.62.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.1.tgz#fcd9739964a40867eed55f1ac318d3909f24b4af"
integrity sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==
"@typescript-eslint/project-service@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.62.0.tgz#ab74c1abb4959fb4c3ba7d7edc6554ee245db990"
integrity sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.61.1"
"@typescript-eslint/types" "^8.61.1"
"@typescript-eslint/tsconfig-utils" "^8.62.0"
"@typescript-eslint/types" "^8.62.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz#2479921a40fdb0afa18f5838fae6167264b417b2"
integrity sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==
"@typescript-eslint/scope-manager@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz#a7a7b428d32444bc9a4fe16f24a78fc124283fd4"
integrity sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==
dependencies:
"@typescript-eslint/types" "8.61.1"
"@typescript-eslint/visitor-keys" "8.61.1"
"@typescript-eslint/types" "8.62.0"
"@typescript-eslint/visitor-keys" "8.62.0"
"@typescript-eslint/tsconfig-utils@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f"
integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==
"@typescript-eslint/tsconfig-utils@^8.61.1":
"@typescript-eslint/tsconfig-utils@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz#9440a673581c6d9de308c4d5803dd52ed5d71729"
integrity sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==
"@typescript-eslint/type-utils@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz#8fa18f453ee140893b47d339d1a6b64cac9b08a1"
integrity sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==
"@typescript-eslint/tsconfig-utils@^8.62.0":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz#e2b5f24fe721044189cb7e81117c96d75979d627"
integrity sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==
"@typescript-eslint/type-utils@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz#6f64d813ed9f340d796baed40cdab86b8e9a491a"
integrity sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==
dependencies:
"@typescript-eslint/types" "8.61.1"
"@typescript-eslint/typescript-estree" "8.61.1"
"@typescript-eslint/utils" "8.61.1"
"@typescript-eslint/types" "8.62.0"
"@typescript-eslint/typescript-estree" "8.62.0"
"@typescript-eslint/utils" "8.62.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a"
integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==
"@typescript-eslint/types@^8.61.1":
"@typescript-eslint/types@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.0.tgz#601427c10203d9f0f34f0b3e474df735eb12b593"
integrity sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==
"@typescript-eslint/typescript-estree@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz#febbe70365ac0bf7611262b61b338fc8797965c7"
integrity sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==
"@typescript-eslint/types@^8.62.0":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.1.tgz#c58be954e483b2fc98275374d5bcb40b99842dc1"
integrity sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==
"@typescript-eslint/typescript-estree@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz#b96b55d02e26aa09434421c3fa678e525ca09a4c"
integrity sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==
dependencies:
"@typescript-eslint/project-service" "8.61.1"
"@typescript-eslint/tsconfig-utils" "8.61.1"
"@typescript-eslint/types" "8.61.1"
"@typescript-eslint/visitor-keys" "8.61.1"
"@typescript-eslint/project-service" "8.62.0"
"@typescript-eslint/tsconfig-utils" "8.62.0"
"@typescript-eslint/types" "8.62.0"
"@typescript-eslint/visitor-keys" "8.62.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.61.1.tgz#ffd1054de7dd33b7873cd6c6713ec6b0366316d3"
integrity sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==
"@typescript-eslint/utils@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.62.0.tgz#b5228524ca1ee51af40e156c82d425dec3e01cfe"
integrity sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.61.1"
"@typescript-eslint/types" "8.61.1"
"@typescript-eslint/typescript-estree" "8.61.1"
"@typescript-eslint/scope-manager" "8.62.0"
"@typescript-eslint/types" "8.62.0"
"@typescript-eslint/typescript-estree" "8.62.0"
"@typescript-eslint/visitor-keys@8.61.1":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz#546cf102b4efdb72a9a08e63a1b0d7d745eb66eb"
integrity sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==
"@typescript-eslint/visitor-keys@8.62.0":
version "8.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz#b6daab190bf8f18612f5b86323469a12288c6b31"
integrity sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==
dependencies:
"@typescript-eslint/types" "8.61.1"
"@typescript-eslint/types" "8.62.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -8316,10 +8316,10 @@ globals@^14.0.0:
resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.6.0:
version "17.6.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.6.0.tgz#0f0be018d5cca8690e6375ead1f65c4bb96191fc"
integrity sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==
globals@^17.7.0:
version "17.7.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.7.0.tgz#553d55090b4dde8209ec2da42580d6e7e7d8b10d"
integrity sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==
globalthis@^1.0.4:
version "1.0.4"
@@ -9454,7 +9454,7 @@ js-yaml@4.1.0:
dependencies:
argparse "^2.0.1"
js-yaml@=4.2.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0:
js-yaml@=4.2.0, js-yaml@^4.1.0, js-yaml@^4.1.1:
version "4.2.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524"
integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==
@@ -9469,6 +9469,13 @@ js-yaml@^3.13.1:
argparse "^1.0.7"
esprima "^4.0.0"
js-yaml@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.1.0.tgz#c084ac880197833810a69e9c7e51eae12ff35448"
integrity sha512-s8VA5jkR8f22S3NAXmhKPFqGUduqZGlsufabVOgN14iTdw/RXcym7bKkbwjxLK9Yw2lEvvmJjFp119+KPeo8Kg==
dependencies:
argparse "^2.0.1"
jsdoc-type-pratt-parser@^4.0.0:
version "4.8.0"
resolved "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz"
@@ -14145,10 +14152,10 @@ swagger-client@3.37.3, swagger-client@^3.37.4:
ramda "^0.30.1"
ramda-adjunct "^5.1.0"
swagger-ui-react@^5.32.7:
version "5.32.7"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.7.tgz#f4e94c8ee9ace175f43696f051594591caa0f530"
integrity sha512-lnT1A7wlj493InhPjdlnFe32cXO7LMEFIfB0frHBSpYK/r9VGVE8+fRGhOI9AIwLXgVRz6M/TO3+OrIOEwz2rw==
swagger-ui-react@^5.32.8:
version "5.32.8"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.8.tgz#0608b45cf552f33fcc9b3fc5e07740c9a854861f"
integrity sha512-Cstx4Tq8fT5l2TBxHxts8pG+ks0qKSkuO1pwUwgrQQiZ241Mqs+KUODLVIonsYXL/gqX143rkcipUa4d0Rid7w==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -14495,15 +14502,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.61.1:
version "8.61.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.61.1.tgz#7c224a9a643b7f42d295c67a75c1e30fee8c3eaa"
integrity sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==
typescript-eslint@^8.62.0:
version "8.62.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.62.0.tgz#7252c3c931637cda28794c0518f321ee89621d67"
integrity sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==
dependencies:
"@typescript-eslint/eslint-plugin" "8.61.1"
"@typescript-eslint/parser" "8.61.1"
"@typescript-eslint/typescript-estree" "8.61.1"
"@typescript-eslint/utils" "8.61.1"
"@typescript-eslint/eslint-plugin" "8.62.0"
"@typescript-eslint/parser" "8.62.0"
"@typescript-eslint/typescript-estree" "8.62.0"
"@typescript-eslint/utils" "8.62.0"
typescript@~6.0.3:
version "6.0.3"

View File

@@ -77,7 +77,7 @@ dependencies = [
# Flask-AppBuilder workaround. Tracking issue:
# https://github.com/apache/superset/issues/33162
"marshmallow>=3.0, <5",
"marshmallow-union>=0.1",
"marshmallow-union>=0.1.15.post1",
"msgpack>=1.2.0, <1.3",
"nh3>=0.3.5, <0.4",
"numpy>1.23.5, <2.3",
@@ -154,7 +154,7 @@ elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
excel = ["xlrd>=2.0.2, <2.1"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
"fastmcp>=3.4.2,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.

View File

@@ -236,7 +236,7 @@ marshmallow-sqlalchemy==1.5.0
# via
# -r requirements/base.in
# flask-appbuilder
marshmallow-union==0.1.15
marshmallow-union==0.1.15.post1
# via apache-superset (pyproject.toml)
mdurl==0.1.2
# via markdown-it-py

View File

@@ -53,7 +53,7 @@ attrs==25.3.0
# requests-cache
# trio
authlib==1.6.12
# via fastmcp
# via fastmcp-slim
babel==2.17.0
# via
# -c requirements/base-constraint.txt
@@ -184,6 +184,7 @@ cryptography==48.0.1
# apache-superset
# authlib
# google-auth
# joserfc
# paramiko
# pyjwt
# pyopenssl
@@ -191,7 +192,7 @@ cryptography==48.0.1
cycler==0.12.1
# via matplotlib
cyclopts==4.2.4
# via fastmcp
# via fastmcp-slim
db-dtypes==1.3.1
# via pandas-gbq
defusedxml==0.7.1
@@ -236,9 +237,11 @@ et-xmlfile==2.0.0
# -c requirements/base-constraint.txt
# openpyxl
exceptiongroup==1.3.0
# via fastmcp
fastmcp==3.2.4
# via fastmcp-slim
fastmcp==3.4.2
# via apache-superset
fastmcp-slim==3.4.2
# via fastmcp
filelock==3.20.3
# via
# -c requirements/base-constraint.txt
@@ -382,7 +385,7 @@ greenlet==3.5.1
# shillelagh
# sqlalchemy
griffelib==2.0.2
# via fastmcp
# via fastmcp-slim
grpcio==1.81.1
# via
# apache-superset
@@ -413,7 +416,7 @@ httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# fastmcp
# fastmcp-slim
# mcp
httpx-sse==0.4.1
# via mcp
@@ -472,12 +475,14 @@ jmespath==1.1.0
# via
# boto3
# botocore
joserfc==1.7.2
# via fastmcp-slim
jsonpath-ng==1.8.0
# via
# -c requirements/base-constraint.txt
# apache-superset
jsonref==1.1.0
# via fastmcp
# via fastmcp-slim
jsonschema==4.23.0
# via
# -c requirements/base-constraint.txt
@@ -487,7 +492,7 @@ jsonschema==4.23.0
# openapi-spec-validator
jsonschema-path==0.3.4
# via
# fastmcp
# fastmcp-slim
# openapi-spec-validator
jsonschema-specifications==2025.4.1
# via
@@ -541,7 +546,7 @@ marshmallow-sqlalchemy==1.5.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder
marshmallow-union==0.1.15
marshmallow-union==0.1.15.post1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -550,7 +555,7 @@ matplotlib==3.9.0
mccabe==0.7.0
# via pylint
mcp==1.24.0
# via fastmcp
# via fastmcp-slim
mdurl==0.1.2
# via
# -c requirements/base-constraint.txt
@@ -594,7 +599,7 @@ odfpy==1.4.1
# -c requirements/base-constraint.txt
# pandas
openapi-pydantic==0.5.1
# via fastmcp
# via fastmcp-slim
openapi-schema-validator==0.6.3
# via
# -c requirements/base-constraint.txt
@@ -606,7 +611,7 @@ openpyxl==3.1.5
# -c requirements/base-constraint.txt
# pandas
opentelemetry-api==1.39.1
# via fastmcp
# via fastmcp-slim
ordered-set==4.1.0
# via
# -c requirements/base-constraint.txt
@@ -627,7 +632,7 @@ packaging==25.0
# deprecation
# docker
# duckdb-engine
# fastmcp
# fastmcp-slim
# google-cloud-bigquery
# gunicorn
# limits
@@ -672,7 +677,7 @@ pip==25.1.1
platformdirs==4.3.8
# via
# -c requirements/base-constraint.txt
# fastmcp
# fastmcp-slim
# pylint
# requests-cache
# virtualenv
@@ -714,7 +719,7 @@ psutil==6.1.0
psycopg2-binary==2.9.12
# via apache-superset
py-key-value-aio==0.4.4
# via fastmcp
# via fastmcp-slim
pyarrow==24.0.0
# via
# -c requirements/base-constraint.txt
@@ -741,7 +746,7 @@ pydantic==2.11.7
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
# fastmcp
# fastmcp-slim
# mcp
# openapi-pydantic
# pydantic-settings
@@ -750,7 +755,9 @@ pydantic-core==2.33.2
# -c requirements/base-constraint.txt
# pydantic
pydantic-settings==2.10.1
# via mcp
# via
# fastmcp-slim
# mcp
pydata-google-auth==1.9.0
# via pandas-gbq
pydruid==0.6.9
@@ -793,7 +800,7 @@ pyparsing==3.2.3
# apache-superset
# matplotlib
pyperclip==1.10.0
# via fastmcp
# via fastmcp-slim
pysocks==1.7.1
# via
# -c requirements/base-constraint.txt
@@ -835,12 +842,14 @@ python-dotenv==1.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
# fastmcp
# fastmcp-slim
# pydantic-settings
python-ldap==3.4.7
# via apache-superset
python-multipart==0.0.29
# via mcp
# via
# fastmcp-slim
# mcp
pytz==2025.2
# via
# -c requirements/base-constraint.txt
@@ -856,7 +865,7 @@ pyyaml==6.0.3
# -c requirements/base-constraint.txt
# apache-superset
# apispec
# fastmcp
# fastmcp-slim
# jsonschema-path
# pre-commit
redis==5.3.1
@@ -899,7 +908,7 @@ rich==13.9.4
# via
# -c requirements/base-constraint.txt
# cyclopts
# fastmcp
# fastmcp-slim
# flask-limiter
# rich-rst
rich-rst==1.3.1
@@ -995,8 +1004,10 @@ sshtunnel==0.4.0
# via
# -c requirements/base-constraint.txt
# apache-superset
starlette==0.49.1
# via mcp
starlette==1.3.1
# via
# fastmcp-slim
# mcp
statsd==4.0.1
# via apache-superset
syntaqlite==0.4.2
@@ -1035,6 +1046,7 @@ typing-extensions==4.15.0
# apache-superset-core
# cattrs
# exceptiongroup
# fastmcp-slim
# grpcio
# limits
# mcp
@@ -1062,7 +1074,7 @@ tzdata==2025.2
tzlocal==5.2
# via trino
uncalled-for==0.2.0
# via fastmcp
# via fastmcp-slim
url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
@@ -1077,7 +1089,7 @@ urllib3==2.7.0
# selenium
uvicorn==0.37.0
# via
# fastmcp
# fastmcp-slim
# mcp
vine==5.1.0
# via
@@ -1093,7 +1105,7 @@ watchdog==6.0.0
# apache-superset
# apache-superset-extensions-cli
watchfiles==1.1.1
# via fastmcp
# via fastmcp-slim
wcwidth==0.2.13
# via
# -c requirements/base-constraint.txt
@@ -1103,7 +1115,7 @@ websocket-client==1.8.0
# -c requirements/base-constraint.txt
# selenium
websockets==15.0.1
# via fastmcp
# via fastmcp-slim
werkzeug==3.1.6
# via
# -c requirements/base-constraint.txt

View File

@@ -91,22 +91,28 @@ _ASF_LICENSE_HEADER = """\
LANGUAGE_NAMES: dict[str, str] = {
"ar": "Arabic",
"ca": "Catalan",
"cs": "Czech",
"de": "German",
"es": "Spanish",
"fa": "Persian (Farsi)",
"fi": "Finnish",
"fr": "French",
"it": "Italian",
"ja": "Japanese",
"ko": "Korean",
"lv": "Latvian",
"mi": "Māori",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"pt_BR": "Brazilian Portuguese",
"ro": "Romanian",
"ru": "Russian",
"sk": "Slovak",
"sl": "Slovenian",
"sr": "Serbian",
"sr_Latn": "Serbian (Latin script)",
"th": "Thai",
"tr": "Turkish",
"uk": "Ukrainian",
"zh": "Chinese (Simplified)",
@@ -346,6 +352,97 @@ def translate_batch(
return parse_response(result.stdout.strip(), len(batch))
def _translate_single_plaintext(
model: str,
target_lang: str,
item: dict[str, Any],
index: dict[str, Any],
) -> str | None:
"""Translate a single entry with a plain-text prompt (no JSON envelope).
Fallback for an entry whose JSON batch response cannot be parsed — typically
because the source string contains literal double-quotes that the model
echoes back unescaped, corrupting the surrounding JSON. Asking for a bare
string sidesteps the JSON contract entirely. Returns the translation text,
or None if the CLI call fails.
"""
claude_bin = shutil.which("claude")
if not claude_bin:
raise RuntimeError(
"claude CLI not found. Install Claude Code or add it to PATH."
)
lines = [
"You are a professional translator specializing in software UI strings.",
f"Translate the following English string into {_lang_name(target_lang)} "
f"({target_lang}).",
"Return ONLY the translation as plain text — no surrounding quotes, no "
"JSON, no markdown fences, no explanation.",
"Preserve all format placeholders exactly (%(name)s, {name}, %s, %d), any "
"HTML tags, and any inner quotation marks.",
"",
f"English: {item['msgid']}",
]
if item.get("msgid_plural"):
lines.append(f"English plural: {item['msgid_plural']}")
refs = index.get(item["index_key"], {})
ref_lines = [
f"{_lang_name(lang)}: {val}"
for lang, val in sorted(refs.items())
if lang != target_lang and isinstance(val, str) and val
]
if ref_lines:
lines.append("")
lines.append("Reference translations in other languages:")
lines.extend(ref_lines)
prompt = "\n".join(lines)
# 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:
return None
text = result.stdout.strip()
# Strip accidental markdown fences or wrapping quotes the model may add.
text = re.sub(r"^```[^\n]*\n?", "", text)
text = re.sub(r"\n?```$", "", text).strip()
if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
text = text[1:-1]
return text or None
def _resilient_translate(
model: str,
target_lang: str,
batch: list[dict[str, Any]],
index: dict[str, Any],
) -> dict[int, str]:
"""Translate a batch, isolating entries that break the JSON response contract.
``translate_batch`` sends the whole batch in one request and parses a single
JSON object back. A source string containing literal double-quotes can make
the model emit unescaped quotes, so ``json.loads`` fails and the ENTIRE batch
would be lost. To salvage the rest, on a parse failure (ValueError) we bisect
the batch and recurse; a lone entry that still fails falls back to a
plain-text prompt via ``_translate_single_plaintext``. Returned keys are
positions within ``batch``. RuntimeError (CLI failure) is left to propagate
to the caller, preserving the existing per-batch failure handling.
"""
try:
return translate_batch(model, target_lang, batch, index)
except ValueError:
if len(batch) == 1:
text = _translate_single_plaintext(model, target_lang, batch[0], index)
return {0: text} if text else {}
mid = len(batch) // 2
left = _resilient_translate(model, target_lang, batch[:mid], index)
right = _resilient_translate(model, target_lang, batch[mid:], index)
return {**left, **{k + mid: v for k, v in right.items()}}
def _apply_plural_translation(entry: polib.POEntry, translation: str) -> None:
"""Distribute a model response across the entry's plural forms.
@@ -462,7 +559,7 @@ def _process_batches(
file=sys.stderr,
)
try:
translations = translate_batch(model, lang, batch_items, index)
translations = _resilient_translate(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)

View File

@@ -20,21 +20,31 @@ Check that source-code changes don't cause translation regressions.
What counts as a regression
---------------------------
A regression is an *existing translation that a source change invalidated*.
The check keys on the **increase in fuzzy entries** rather than a drop in the
translated count, because a count drop happens identically for a benign
*deletion* and a real *rename*, so it cannot distinguish the two — whereas a
``#, fuzzy`` marker unambiguously flags a stranded translation.
A regression is an *existing translation that a source change invalidated*:
a message that was a **confirmed, non-fuzzy translation** in the baseline and
is **fuzzy** after the PR. The check keys on this per-``msgid`` transition
rather than on the aggregate count of fuzzy entries, because a bare count
cannot tell apart two changes that move the fuzzy total by the same amount:
* ``translated -> fuzzy`` — a reworded source string stranded a real
translation. **This is the regression.**
* ``untranslated -> fuzzy`` — an empty ``msgstr`` was filled with a fuzzy
(unconfirmed) guess, e.g. an AI backfill committed as ``#, fuzzy``. No
existing translation was lost, so this is **not** a regression and must
pass.
Keying on the per-entry transition lets a backfill PR commit fuzzy guesses for
previously-untranslated strings (the ja/fi catalog backfills) without tripping
the check, while still catching a genuine invalidation even when the same PR
also adds new strings (which a count-delta heuristic would let mask it).
Note ``babel_update.sh`` runs ``pybabel update`` with ``--no-fuzzy-matching``,
so *adding* (or renaming) a source string does **not** auto-generate a fuzzy
guess against an unrelated existing translation — new strings land as cleanly
untranslated (empty ``msgstr``). This deliberately avoids the prior behaviour
where *every* PR that merely added a translatable string tripped this check on
spurious fuzzies. As a result the check now guards against ``#, fuzzy`` entries
that arrive another way — e.g. a committed ``.po`` edit — rather than ones the
update step synthesises. *Deleting* a string is still not a regression: with
``--ignore-obsolete`` it is simply dropped and no fuzzy is created.
untranslated (empty ``msgstr``). The fuzzies this check sees therefore arrive
another way — typically a committed ``.po`` edit. *Deleting* a string is still
not a regression: with ``--ignore-obsolete`` it is simply dropped and no fuzzy
is created.
Usage
-----
@@ -127,28 +137,72 @@ def count_stats(po_file: Path) -> dict[str, int]:
}
def entry_keys(po_file: Path) -> dict[str, list[str]]:
"""Return per-``msgid`` key sets for a .po file.
``translated_keys`` lists the non-fuzzy, non-obsolete entries with a
populated ``msgstr`` — confirmed translations a source reword could strand.
``fuzzy_keys`` lists the non-obsolete entries carrying the ``fuzzy`` flag
(however they arrived — a committed backfill guess or a real invalidation).
A key combines ``msgctxt`` and ``msgid`` (gettext's own identity rule) so
context-disambiguated entries stay distinct. The header entry (empty
``msgid``) is ignored. The regression check compares the baseline's
``translated_keys`` against the PR's ``fuzzy_keys``: their intersection is
exactly the set of confirmed translations the PR turned fuzzy.
Raises:
OSError: if ``polib`` cannot read or parse the file. As with a msgfmt
failure, a catalog we cannot parse is surfaced rather than silently
counted as empty.
"""
import polib # type: ignore[import-untyped] # noqa: PLC0415
translated_keys: list[str] = []
fuzzy_keys: list[str] = []
for entry in polib.pofile(str(po_file)):
if entry.obsolete or not entry.msgid:
continue
key = f"{entry.msgctxt}\x04{entry.msgid}" if entry.msgctxt else entry.msgid
if "fuzzy" in entry.flags:
fuzzy_keys.append(key)
elif (
all(entry.msgstr_plural.values())
if entry.msgid_plural
else bool(entry.msgstr)
):
translated_keys.append(key)
return {"translated_keys": translated_keys, "fuzzy_keys": fuzzy_keys}
def get_counts(
translations_dir: Path,
failures: Optional[set[str]] = None,
) -> dict[str, dict[str, int]]:
) -> dict[str, dict[str, object]]:
"""Count translated/fuzzy entries for every ``.po`` file in a directory.
Each language maps to ``{"translated", "fuzzy", "translated_keys",
"fuzzy_keys"}`` — aggregate counts (for the human-readable summary) plus the
per-``msgid`` key sets the regression check actually keys on.
If ``failures`` is provided, the name of each language whose ``.po`` file
is present on disk but could not be counted (msgfmt non-zero exit, or
unparseable output) is added to it. Such a language is deliberately absent
from the returned mapping — but, unlike a language whose catalog was simply
deleted, it must not be mistaken for an intentional removal: a caller that
cares about the distinction (see :func:`cmd_compare`) can inspect
``failures`` and treat it as a hard error.
is present on disk but could not be counted (msgfmt non-zero exit,
unparseable output, or a polib parse error) is added to it. Such a language
is deliberately absent from the returned mapping — but, unlike a language
whose catalog was simply deleted, it must not be mistaken for an intentional
removal: a caller that cares about the distinction (see :func:`cmd_compare`)
can inspect ``failures`` and treat it as a hard error.
"""
counts: dict[str, dict[str, int]] = {}
counts: dict[str, dict[str, object]] = {}
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_stats(po_file)
except (subprocess.CalledProcessError, RuntimeError) as exc:
stats: dict[str, object] = dict(count_stats(po_file))
stats.update(entry_keys(po_file))
counts[lang] = stats
except (subprocess.CalledProcessError, RuntimeError, OSError) 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
@@ -164,42 +218,73 @@ def get_counts(
return counts
def _normalize(entry: object) -> dict[str, int]:
"""Coerce a baseline entry into ``{"translated", "fuzzy"}``.
def _normalize(entry: object) -> dict[str, object]:
"""Coerce a baseline entry into ``{"translated", "fuzzy", *_keys}``.
Tolerates the legacy baseline format where each language mapped directly to
an integer translated count (no fuzzy data); such entries contribute a
fuzzy baseline of 0.
``translated_keys``/``fuzzy_keys`` are the per-``msgid`` sets the check
keys on. They are ``None`` (not ``[]``) when the baseline predates the
per-entry format — an absent set means "unknown", which routes
:func:`cmd_compare` to the coarse aggregate fallback, whereas an empty list
is a known-empty set. Legacy formats — a ``{"translated", "fuzzy"}`` dict
with no key sets, or a bare integer translated count — are both tolerated.
"""
if isinstance(entry, dict):
return {
"translated": int(entry.get("translated", 0)),
"fuzzy": int(entry.get("fuzzy", 0)),
"translated_keys": (
list(entry["translated_keys"]) if "translated_keys" in entry else None
),
"fuzzy_keys": (
list(entry["fuzzy_keys"]) if "fuzzy_keys" in entry else None
),
}
if isinstance(entry, int):
return {"translated": entry, "fuzzy": 0}
return {
"translated": entry,
"fuzzy": 0,
"translated_keys": None,
"fuzzy_keys": None,
}
raise TypeError(f"Unsupported baseline entry: {entry!r}")
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
def _key_list(stats: dict[str, object], field: str) -> Optional[list[str]]:
"""Return ``stats[field]`` as a list of keys, or ``None`` if unavailable.
A missing or non-list value reads as "unknown" so the caller can fall back
to the aggregate comparison instead of treating it as an empty key set.
"""
value = stats.get(field)
return list(value) if isinstance(value, list) else None
def _count(stats: dict[str, object], field: str) -> int:
"""Return ``stats[field]`` as an int count, defaulting to 0."""
value = stats.get(field, 0)
return value if isinstance(value, int) else 0
def build_regression_report(regressions: list[tuple[str, int, int, int]]) -> str:
"""Build a markdown report for posting as a PR comment.
Each regression tuple is ``(lang, before_fuzzy, after_fuzzy)``.
Each regression tuple is ``(lang, before_fuzzy, after_fuzzy, invalidated)``
where ``invalidated`` is the number of confirmed translations the PR turned
fuzzy.
"""
rows = "\n".join(
f"| `{lang}` | {b} | {a} | +{a - b} |" for lang, b, a in regressions
)
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
rows = "\n".join(f"| `{lang}` | {n} |" for lang, _b, _a, n in regressions)
affected = ", ".join(f"`{lang}`" for lang, *_ in regressions)
return (
"## ⚠️ Translation Regression Detected\n\n"
f"A source change in this PR renamed or reworded strings, invalidating "
f"existing translations (they are now `#, fuzzy`) in {affected}. Please "
f"resolve the affected `.po` files before merging.\n\n"
"_Note: intentionally **deleting** a translatable string is not a "
"regression and is not flagged here — only translations invalidated by "
"a renamed/reworded source string are._\n\n"
"| Language | Fuzzy before | Fuzzy after | New |\n"
"|----------|-------------:|------------:|----:|\n"
"_Note: neither intentionally **deleting** a translatable string nor "
"filling a previously-**untranslated** entry with a fuzzy guess (e.g. an "
"AI backfill) is a regression — only a confirmed translation that a "
"renamed/reworded source string turned fuzzy is flagged here._\n\n"
"| Language | Invalidated translations |\n"
"|----------|-------------------------:|\n"
f"{rows}\n\n"
"### How to fix\n\n"
"**1. Install dependencies** (if not already set up):\n\n"
@@ -231,6 +316,41 @@ def cmd_count(translations_dir: Path) -> None:
print(json.dumps(counts, indent=2))
def _detect_regressions(
before: dict[str, dict[str, object]],
after: dict[str, dict[str, object]],
) -> list[tuple[str, int, int, int]]:
"""Return ``(lang, before_fuzzy, after_fuzzy, invalidated)`` per regressed lang.
A regression is a key in the baseline's ``translated_keys`` that is fuzzy
after the PR — a confirmed translation a source reword stranded. Filling a
previously-untranslated entry with a fuzzy guess (backfill) is therefore not
flagged (its key was absent from the baseline's translated set), and neither
is deleting a string (with ``--ignore-obsolete`` it drops, creating no
fuzzy). When per-entry key data is unavailable (a legacy baseline, or a
catalog whose key set could not be read), fall back to the coarse rule: any
net increase in the aggregate fuzzy count.
"""
regressions: list[tuple[str, int, int, int]] = []
for lang, before_stats in sorted(before.items()):
after_stats = after.get(lang)
if after_stats is None:
# Catalog absent from `after`: an intentional deletion (a
# present-but-uncountable catalog was already caught by the caller).
continue
b_fuzzy = _count(before_stats, "fuzzy")
a_fuzzy = _count(after_stats, "fuzzy")
before_translated = before_stats.get("translated_keys")
after_fuzzy = _key_list(after_stats, "fuzzy_keys")
if isinstance(before_translated, list) and after_fuzzy is not None:
invalidated = set(after_fuzzy) & set(before_translated)
if invalidated:
regressions.append((lang, b_fuzzy, a_fuzzy, len(invalidated)))
elif a_fuzzy > b_fuzzy:
regressions.append((lang, b_fuzzy, a_fuzzy, a_fuzzy - b_fuzzy))
return regressions
def cmd_compare(
before_path: str,
translations_dir: Path,
@@ -259,23 +379,12 @@ def cmd_compare(
)
sys.exit(1)
# A regression is an *increase* in fuzzy entries: the PR's source diff
# renamed/reworded strings, leaving their committed translations stranded.
# A plain drop in the translated count is NOT used — deleting a string
# lowers it identically to a rename but is a legitimate change, and with
# `pybabel update --ignore-obsolete` a deletion creates no fuzzy entry.
regressions: list[tuple[str, int, int]] = []
for lang, before_stats in sorted(before.items()):
after_stats = after.get(lang, {"translated": 0, "fuzzy": 0})
if after_stats["fuzzy"] > before_stats["fuzzy"]:
regressions.append((lang, before_stats["fuzzy"], after_stats["fuzzy"]))
if regressions:
if regressions := _detect_regressions(before, after):
print("Translation regression detected!\n")
for lang, b, a in regressions:
for lang, _b, _a, n in regressions:
print(
f" {lang}: {a - b} translation(s) invalidated "
f"(fuzzy {b} -> {a}) by a renamed/reworded source string"
f" {lang}: {n} confirmed translation(s) invalidated "
f"(now fuzzy) by a renamed/reworded source string"
)
print(
"\nResolve the newly-fuzzy entries in the affected .po files "
@@ -290,14 +399,16 @@ def cmd_compare(
# All good — print a summary so it's easy to read in CI logs.
print("No translation regressions.\n")
for lang in sorted(after):
before_stats = before.get(lang, {"translated": 0, "fuzzy": 0})
before_stats: dict[str, object] = before.get(lang, {})
after_stats = after[lang]
t_delta = after_stats["translated"] - before_stats["translated"]
f_delta = after_stats["fuzzy"] - before_stats["fuzzy"]
b_translated = _count(before_stats, "translated")
a_translated = _count(after_stats, "translated")
b_fuzzy = _count(before_stats, "fuzzy")
a_fuzzy = _count(after_stats, "fuzzy")
print(
f" {lang}: translated {before_stats['translated']} -> "
f"{after_stats['translated']} ({t_delta:+d}), fuzzy "
f"{before_stats['fuzzy']} -> {after_stats['fuzzy']} ({f_delta:+d})"
f" {lang}: translated {b_translated} -> {a_translated} "
f"({a_translated - b_translated:+d}), fuzzy "
f"{b_fuzzy} -> {a_fuzzy} ({a_fuzzy - b_fuzzy:+d})"
)

View File

@@ -20,4 +20,5 @@
module.exports = {
presets: ["@babel/preset-typescript", "@babel/preset-env"],
sourceMaps: true,
ignore: ["**/*.test.ts"],
};

View File

@@ -22,7 +22,7 @@
"module": "lib/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc && babel src --out-dir lib --extensions '.ts,.tsx' && webpack --mode production",
"build": "tsc && babel src --out-dir lib --extensions '.ts' && webpack --mode production",
"ci:release": "node ./release-if-necessary.js",
"test": "vitest --run --dir src"
},

View File

@@ -23,6 +23,7 @@
],
"exclude": [
"src/**/*.test.ts",
"dist",
"lib",
"node_modules"

View File

@@ -19,9 +19,8 @@
export const DASHBOARD_LIST = '/dashboard/list/';
export const CHART_LIST = '/chart/list/';
export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/';
export const SAMPLE_DASHBOARD_1 = '/superset/dashboard/1-sample-dashboard/';
export const SUPPORTED_CHARTS_DASHBOARD =
'/superset/dashboard/supported_charts_dash/';
export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
export const WORLD_HEALTH_DASHBOARD = '/dashboard/world_health/';
export const SAMPLE_DASHBOARD_1 = '/dashboard/1-sample-dashboard/';
export const SUPPORTED_CHARTS_DASHBOARD = '/dashboard/supported_charts_dash/';
export const TABBED_DASHBOARD = '/dashboard/tabbed_dash/';
export const DATABASE_LIST = '/databaseview/list';

View File

@@ -91,7 +91,7 @@
"dayjs": "^1.11.21",
"dom-to-image-more": "^3.10.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"echarts": "^6.1.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.2",
@@ -187,7 +187,7 @@
"@storybook/react-webpack5": "10.4.4",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.41",
"@swc/core": "^1.15.43",
"@swc/plugin-emotion": "^14.14.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
@@ -8439,9 +8439,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8459,9 +8456,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8479,9 +8473,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8499,9 +8490,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8519,9 +8507,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8539,9 +8524,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8559,9 +8541,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8579,9 +8558,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -9498,9 +9474,9 @@
}
},
"node_modules/@sigstore/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.0.tgz",
"integrity": "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz",
"integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -9666,14 +9642,14 @@
}
},
"node_modules/@sigstore/verify": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz",
"integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz",
"integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@sigstore/bundle": "^4.0.0",
"@sigstore/core": "^3.1.0",
"@sigstore/core": "^3.2.1",
"@sigstore/protobuf-specs": "^0.5.0"
},
"engines": {
@@ -10556,15 +10532,15 @@
}
},
"node_modules/@swc/core": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz",
"integrity": "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
"@swc/types": "^0.1.27"
},
"engines": {
"node": ">=10"
@@ -10574,18 +10550,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.41",
"@swc/core-darwin-x64": "1.15.41",
"@swc/core-linux-arm-gnueabihf": "1.15.41",
"@swc/core-linux-arm64-gnu": "1.15.41",
"@swc/core-linux-arm64-musl": "1.15.41",
"@swc/core-linux-ppc64-gnu": "1.15.41",
"@swc/core-linux-s390x-gnu": "1.15.41",
"@swc/core-linux-x64-gnu": "1.15.41",
"@swc/core-linux-x64-musl": "1.15.41",
"@swc/core-win32-arm64-msvc": "1.15.41",
"@swc/core-win32-ia32-msvc": "1.15.41",
"@swc/core-win32-x64-msvc": "1.15.41"
"@swc/core-darwin-arm64": "1.15.43",
"@swc/core-darwin-x64": "1.15.43",
"@swc/core-linux-arm-gnueabihf": "1.15.43",
"@swc/core-linux-arm64-gnu": "1.15.43",
"@swc/core-linux-arm64-musl": "1.15.43",
"@swc/core-linux-ppc64-gnu": "1.15.43",
"@swc/core-linux-s390x-gnu": "1.15.43",
"@swc/core-linux-x64-gnu": "1.15.43",
"@swc/core-linux-x64-musl": "1.15.43",
"@swc/core-win32-arm64-msvc": "1.15.43",
"@swc/core-win32-ia32-msvc": "1.15.43",
"@swc/core-win32-x64-msvc": "1.15.43"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -10597,9 +10573,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz",
"integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz",
"integrity": "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==",
"cpu": [
"arm64"
],
@@ -10613,9 +10589,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz",
"integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz",
"integrity": "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==",
"cpu": [
"x64"
],
@@ -10629,9 +10605,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz",
"integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz",
"integrity": "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==",
"cpu": [
"arm"
],
@@ -10645,9 +10621,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz",
"integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz",
"integrity": "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==",
"cpu": [
"arm64"
],
@@ -10661,9 +10637,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz",
"integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz",
"integrity": "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==",
"cpu": [
"arm64"
],
@@ -10677,9 +10653,9 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz",
"integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz",
"integrity": "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==",
"cpu": [
"ppc64"
],
@@ -10693,9 +10669,9 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz",
"integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz",
"integrity": "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==",
"cpu": [
"s390x"
],
@@ -10709,9 +10685,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz",
"integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz",
"integrity": "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==",
"cpu": [
"x64"
],
@@ -10725,9 +10701,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz",
"integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz",
"integrity": "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==",
"cpu": [
"x64"
],
@@ -10741,9 +10717,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz",
"integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz",
"integrity": "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==",
"cpu": [
"arm64"
],
@@ -10757,9 +10733,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz",
"integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz",
"integrity": "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==",
"cpu": [
"ia32"
],
@@ -10773,9 +10749,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz",
"integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==",
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz",
"integrity": "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==",
"cpu": [
"x64"
],
@@ -10834,9 +10810,9 @@
}
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@@ -18734,13 +18710,13 @@
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
"zrender": "6.1.0"
}
},
"node_modules/echarts/node_modules/tslib": {
@@ -20297,9 +20273,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"funding": [
{
"type": "github",
@@ -38457,18 +38433,18 @@
"license": "ISC"
},
"node_modules/sigstore": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz",
"integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz",
"integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@sigstore/bundle": "^4.0.0",
"@sigstore/core": "^3.1.0",
"@sigstore/core": "^3.2.1",
"@sigstore/protobuf-specs": "^0.5.0",
"@sigstore/sign": "^4.1.0",
"@sigstore/tuf": "^4.0.1",
"@sigstore/verify": "^3.1.0"
"@sigstore/sign": "^4.1.1",
"@sigstore/tuf": "^4.0.2",
"@sigstore/verify": "^3.1.1"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
@@ -44157,9 +44133,9 @@
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"

View File

@@ -174,7 +174,7 @@
"dayjs": "^1.11.21",
"dom-to-image-more": "^3.10.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"echarts": "^6.1.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.2",
@@ -270,7 +270,7 @@
"@storybook/react-webpack5": "10.4.4",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.41",
"@swc/core": "^1.15.43",
"@swc/plugin-emotion": "^14.14.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
@@ -413,6 +413,7 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"fast-xml-parser": "^5.8.0",
"fast-uri": "^3.1.3",
"jest-mock": "^30.4.0",
"jest-runtime": "^30.4.0",
"@jest/globals": "^30.4.0",

View File

@@ -238,11 +238,16 @@ export const xAxisForceCategoricalControl = {
return state?.form_data?.x_axis_sort !== undefined || control.value;
},
renderTrigger: true,
// Expose the toggle for numeric and temporal x-axes. Temporal columns
// default to a continuous time scale, where ECharts places ticks at "nice"
// intervals that don't align with the actual buckets (e.g. weekly grain
// markers landing between month ticks). Treating the axis as categorical
// lets each bucket map to a discrete, tick-aligned category.
visibility: ({ controls }: { controls: ControlStateMapping }) =>
checkColumnType(
getColumnLabel(controls?.x_axis?.value as QueryFormColumn),
controls?.datasource?.datasource,
[GenericDataType.Numeric],
[GenericDataType.Numeric, GenericDataType.Temporal],
),
shouldMapStateToProps: () => true,
},

View File

@@ -20,7 +20,11 @@
import { GenericDataType } from '@apache-superset/core/common';
import { xAxisForceCategoricalControl } from '../../src/shared-controls/customControls';
import { checkColumnType } from '../../src/utils/checkColumnType';
import type { ControlState } from '@superset-ui/chart-controls';
import type {
ControlPanelState,
ControlState,
ControlStateMapping,
} from '@superset-ui/chart-controls';
jest.mock('../../src/utils/checkColumnType');
jest.mock('@superset-ui/core', () => ({
@@ -39,12 +43,12 @@ test('xAxisForceCategoricalControl should not treat temporal columns as categori
controls: {
x_axis: { value: 'date_column' },
datasource: { datasource: {} },
},
};
} as unknown as ControlStateMapping,
} as unknown as ControlPanelState;
const result = xAxisForceCategoricalControl.config.initialValue!(
control,
state as any,
state,
);
// Verify: should return control value (false) for non-numeric columns
@@ -55,3 +59,27 @@ test('xAxisForceCategoricalControl should not treat temporal columns as categori
mockCheckColumnType.mockClear();
});
test('xAxisForceCategoricalControl is visible for numeric and temporal x-axes', () => {
const mockCheckColumnType = jest.mocked(checkColumnType);
mockCheckColumnType.mockReturnValue(true);
const controls = {
x_axis: { value: 'date_column' },
datasource: { datasource: {} },
} as unknown as ControlStateMapping;
const visible = xAxisForceCategoricalControl.config.visibility!({
controls,
});
expect(visible).toBe(true);
// Temporal columns must be included so the toggle is exposed for time-grain
// charts (e.g. weekly grain), where the time scale misaligns ticks/markers.
expect(mockCheckColumnType).toHaveBeenCalledWith('date_column', {}, [
GenericDataType.Numeric,
GenericDataType.Temporal,
]);
mockCheckColumnType.mockClear();
});

View File

@@ -109,7 +109,7 @@ export default class ChartClient {
(await buildQueryRegistry.get(visType)) ?? (() => formData);
const requestConfig: RequestConfig = useLegacyApi
? {
endpoint: '/superset/explore_json/',
endpoint: '/explore_json/',
postPayload: {
form_data: buildQuery(formData),
},
@@ -139,7 +139,7 @@ export default class ChartClient {
): Promise<Datasource> {
return this.client
.get({
endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
...options,
} as RequestConfig)
.then(response => response.json as Datasource);

View File

@@ -28,6 +28,7 @@ import {
RequestConfig,
getClientErrorObject,
} from '../..';
import type { HandlerFunction } from '../types/Base';
import { Loading } from '../../components/Loading';
import ChartClient from '../clients/ChartClient';
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
@@ -262,9 +263,7 @@ export default function StatefulChart(props: StatefulChartProps) {
if (!useLegacyApi && !queryContext.queries) {
queryContext = { queries: [queryContext] };
}
const endpoint = useLegacyApi
? '/superset/explore_json/'
: '/api/v1/chart/data';
const endpoint = useLegacyApi ? '/explore_json/' : '/api/v1/chart/data';
const requestConfig: RequestConfig = {
endpoint,
@@ -482,7 +481,7 @@ export default function StatefulChart(props: StatefulChartProps) {
enableNoResults={enableNoResults}
noResults={NoDataComponent && <NoDataComponent />}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure}
onRenderFailure={onRenderFailure as HandlerFunction | undefined}
hooks={hooks}
/>
);

View File

@@ -41,6 +41,7 @@ export const ActionButton = ({
<span
role="button"
tabIndex={0}
aria-label={typeof tooltip === 'string' ? tooltip : label}
css={css`
cursor: pointer;
color: ${theme.colorIcon};

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState, forwardRef, ComponentType } from 'react';
import React, {
useEffect,
useState,
forwardRef,
ComponentType,
ForwardedRef,
} from 'react';
import { Loading } from '../Loading';
import type { PlaceholderProps } from './types';
@@ -93,7 +99,7 @@ export function AsyncEsmComponent<
// @ts-expect-error -- generic forwardRef has PropsWithoutRef incompatibility with FullProps
const AsyncComponent: AsyncComponent = forwardRef(function AsyncComponent(
props: FullProps,
ref,
ref: ForwardedRef<ComponentType<FullProps>>,
) {
const [loaded, setLoaded] = useState(component !== undefined);
useEffect(() => {

View File

@@ -19,7 +19,7 @@
import {
cloneElement,
forwardRef,
RefObject,
ForwardedRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
@@ -54,7 +54,7 @@ export const DropdownContainer = forwardRef(
forceRender,
style,
}: DropdownContainerProps,
outerRef: RefObject<DropdownRef>,
outerRef: ForwardedRef<DropdownRef>,
) => {
const theme = useTheme();
const { ref, width = 0 } = useResizeDetector<HTMLDivElement>();

View File

@@ -331,12 +331,21 @@ export const antdEnhancedIcons: Record<
.reduce(
(acc, key) => {
acc[key as AntdIconNames] = forwardRef<HTMLSpanElement, IconType>(
(props, ref) => (
(
{
// Forward-compat: TS 6.0 treats IconComponentProps.component as a
// different shape than BaseIconProps.component; strip it from spread
// props so our own component binding is authoritative.
component: _ignoredComponent,
...rest
},
ref,
) => (
<BaseIconComponent
ref={ref}
component={AntdIcons[key as AntdIconNames]}
fileName={key}
{...props}
{...rest}
/>
),
);

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef, useState, ReactNode, MouseEvent } from 'react';
import {
forwardRef,
ForwardedRef,
useState,
ReactNode,
MouseEvent,
} from 'react';
import { Button } from '../Button';
import { Modal } from '../Modal';
@@ -54,7 +60,7 @@ export interface ModalTriggerRef {
}
export const ModalTrigger = forwardRef(
(props: ModalTriggerProps, ref: ModalTriggerRef | null) => {
(props: ModalTriggerProps, ref: ForwardedRef<ModalTriggerRef['current']>) => {
const [showModal, setShowModal] = useState(false);
const {
beforeOpen = () => {},
@@ -87,8 +93,14 @@ export const ModalTrigger = forwardRef(
setShowModal(true);
};
if (ref) {
ref.current = { close, open, showModal }; // eslint-disable-line
// Forward both callback refs (e.g. `(value) => setRef(value)`) and
// object refs. Without the callback-ref branch, parents that pass a
// function ref get silently no-op'd and can't call close/open/showModal.
const refValue = { close, open, showModal };
if (typeof ref === 'function') {
ref(refValue);
} else if (ref) {
ref.current = refValue; // eslint-disable-line
}
/* eslint-disable jsx-a11y/interactive-supports-focus */

View File

@@ -18,6 +18,7 @@
*/
import {
forwardRef,
ForwardedRef,
FocusEvent,
ReactElement,
RefObject,
@@ -38,6 +39,8 @@ import {
getClientErrorObject,
} from '@superset-ui/core';
import {
BaseOptionType,
DefaultOptionType,
LabeledValue as AntdLabeledValue,
RefSelectProps,
} from 'antd/es/select';
@@ -146,7 +149,7 @@ const AsyncSelect = forwardRef(
maxTagCount: propsMaxTagCount,
...props
}: AsyncSelectProps,
ref: RefObject<AsyncSelectRef>,
ref: ForwardedRef<AsyncSelectRef>,
) => {
const isSingleMode = mode === 'single';
const [selectValue, setSelectValue] = useState(value);
@@ -324,7 +327,14 @@ const AsyncSelect = forwardRef(
mergedData = prevOptions
.filter(previousOption => !dataValues.has(previousOption.value))
.concat(data)
.sort(sortComparatorForNoSearch);
// Forward-compat: TS 6.0 infers stricter antd option types; widen
// the comparator to accept the broader DefaultOptionType shape.
.sort(
sortComparatorForNoSearch as unknown as (
a: BaseOptionType | DefaultOptionType,
b: BaseOptionType | DefaultOptionType,
) => number,
);
return mergedData;
});
}
@@ -509,7 +519,13 @@ const AsyncSelect = forwardRef(
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
const sortedOptions = selectOptions
.slice()
.sort(sortComparatorForNoSearch);
// Forward-compat: see note in mergeData above.
.sort(
sortComparatorForNoSearch as unknown as (
a: BaseOptionType | DefaultOptionType,
b: BaseOptionType | DefaultOptionType,
) => number,
);
if (!isEqual(sortedOptions, selectOptions)) {
setSelectOptions(sortedOptions);
}
@@ -632,14 +648,16 @@ const AsyncSelect = forwardRef(
setAllValuesLoaded(false);
};
useImperativeHandle(
ref,
() => ({
...(ref.current as RefSelectProps),
useImperativeHandle(ref, () => {
const current =
ref && typeof ref !== 'function' && ref.current
? (ref.current as RefSelectProps)
: ({} as RefSelectProps);
return {
...current,
clearCache,
}),
[ref],
);
};
}, [ref]);
const getPastedTextValue = useCallback(
async (text: string) => {
@@ -705,8 +723,21 @@ const AsyncSelect = forwardRef(
data-test={ariaLabel || name}
autoClearSearchValue={autoClearSearchValue}
popupRender={popupRender}
filterOption={handleFilterOption}
filterSort={sortComparatorWithSearch}
// Forward-compat: TS 6.0 infers stricter antd option types; local
// helpers typed against AntdLabeledValue are behaviorally compatible
// with the broader BaseOptionType/DefaultOptionType antd expects.
filterOption={
handleFilterOption as unknown as (
search: string,
option?: BaseOptionType | DefaultOptionType,
) => boolean
}
filterSort={
sortComparatorWithSearch as unknown as (
a: BaseOptionType | DefaultOptionType,
b: BaseOptionType | DefaultOptionType,
) => number
}
getPopupContainer={
getPopupContainer || (triggerNode => triggerNode.parentNode)
}
@@ -716,13 +747,26 @@ const AsyncSelect = forwardRef(
mode={mappedMode}
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
onBlur={handleOnBlur}
onDeselect={handleOnDeselect}
// Forward-compat: TS 6.0 narrows the Select value type handed to
// SelectHandler; our local handlers already accept the broader union.
onDeselect={
handleOnDeselect as unknown as (
value: unknown,
option: BaseOptionType | DefaultOptionType,
) => void
}
onOpenChange={handleOnDropdownVisibleChange}
// @ts-expect-error
// @ts-expect-error antd Select does not declare onPaste on its prop
// surface, but the underlying input accepts it and we rely on that.
onPaste={onPaste}
onPopupScroll={handlePagination}
onSearch={showSearch ? handleOnSearch : undefined}
onSelect={handleOnSelect}
onSelect={
handleOnSelect as unknown as (
value: unknown,
option: BaseOptionType | DefaultOptionType,
) => void
}
onClear={handleClear}
options={fullSelectOptions}
optionRender={option => <Space>{option.label || option.value}</Space>}

View File

@@ -34,6 +34,8 @@ import { t } from '@apache-superset/core/translation';
import { ensureIsArray, formatNumber, usePrevious } from '@superset-ui/core';
import { Constants } from '@superset-ui/core/components';
import {
BaseOptionType,
DefaultOptionType,
LabeledValue as AntdLabeledValue,
RefSelectProps,
} from 'antd/es/select';
@@ -212,7 +214,17 @@ const Select = forwardRef(
);
const initialOptionsSorted = useMemo(
() => initialOptions.slice().sort(sortSelectedFirst),
() =>
initialOptions
.slice()
// Forward-compat: TS 6.0 infers stricter antd option types; widen the
// comparator to accept the broader DefaultOptionType shape.
.sort(
sortSelectedFirst as unknown as (
a: BaseOptionType | DefaultOptionType,
b: BaseOptionType | DefaultOptionType,
) => number,
),
[initialOptions, sortSelectedFirst],
);
@@ -240,7 +252,17 @@ const Select = forwardRef(
missingValues.length > 0
? missingValues.concat(selectOptions)
: selectOptions;
return result.slice().sort(sortSelectedFirst);
return (
result
.slice()
// Forward-compat: see note on initialOptionsSorted.
.sort(
sortSelectedFirst as unknown as (
a: BaseOptionType | DefaultOptionType,
b: BaseOptionType | DefaultOptionType,
) => number,
)
);
}, [selectOptions, selectValue, sortSelectedFirst]);
const enabledOptions = useMemo(
@@ -773,8 +795,21 @@ const Select = forwardRef(
data-test={ariaLabel || name}
autoClearSearchValue={autoClearSearchValue}
popupRender={popupRender}
filterOption={handleFilterOption}
filterSort={sortComparatorWithSearch}
// Forward-compat: TS 6.0 infers stricter antd option types; local
// helpers typed against AntdLabeledValue are behaviorally compatible
// with the broader BaseOptionType/DefaultOptionType antd expects.
filterOption={
handleFilterOption as unknown as (
search: string,
option?: BaseOptionType | DefaultOptionType,
) => boolean
}
filterSort={
sortComparatorWithSearch as unknown as (
a: BaseOptionType | DefaultOptionType,
b: BaseOptionType | DefaultOptionType,
) => number
}
getPopupContainer={
getPopupContainer || (triggerNode => triggerNode.parentNode)
}
@@ -785,13 +820,26 @@ const Select = forwardRef(
mode={mappedMode}
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
onBlur={handleOnBlur}
onDeselect={handleOnDeselect}
// Forward-compat: TS 6.0 narrows the Select value type handed to
// SelectHandler; our local handlers already accept the broader union.
onDeselect={
handleOnDeselect as unknown as (
value: unknown,
option: BaseOptionType | DefaultOptionType,
) => void
}
onOpenChange={handleOnDropdownVisibleChange}
// @ts-expect-error
// @ts-expect-error antd Select does not declare onPaste on its prop
// surface, but the underlying input accepts it and we rely on that.
onPaste={onPaste}
onPopupScroll={undefined}
onSearch={shouldShowSearch ? handleOnSearch : undefined}
onSelect={handleOnSelect}
onSelect={
handleOnSelect as unknown as (
value: unknown,
option: BaseOptionType | DefaultOptionType,
) => void
}
onClear={handleClear}
placeholder={placeholder}
tokenSeparators={tokenSeparators}

View File

@@ -84,8 +84,8 @@ const VirtualTable = <RecordType extends object>(
allowHTML = false,
} = props;
const [tableWidth, setTableWidth] = useState<number>(0);
const onResize = useCallback((width: number) => {
setTableWidth(width);
const onResize = useCallback((width?: number) => {
setTableWidth(width ?? 0);
}, []);
const { ref } = useResizeDetector({ onResize });
const theme = useTheme();

View File

@@ -29,7 +29,7 @@ interface IInteractiveColumn extends HTMLElement {
export default class InteractiveTableUtils {
tableRef: HTMLTableElement | null;
columnRef: IInteractiveColumn | null;
columnRef: IInteractiveColumn | null = null;
setDerivedColumns: Function;

View File

@@ -27,7 +27,12 @@ import {
} from 'react-table';
import { styled } from '@apache-superset/core/theme';
import { Table, TableSize } from '@superset-ui/core/components/Table';
import { TableRowSelection, SorterResult } from 'antd/es/table/interface';
import {
ColumnsType,
TableRowSelection,
SorterResult,
} from 'antd/es/table/interface';
import type { TableProps } from 'antd/es/table';
import { mapColumns, mapRows } from './utils';
export interface TableCollectionProps<T extends object> {
@@ -303,7 +308,10 @@ function TableCollection<T extends object>({
<StyledTable
loading={loading}
sticky={sticky ?? false}
columns={mappedColumns}
// Forward-compat: TS 6.0 tightens antd Table's generic inference so our
// typed-against-react-table mapped columns must be widened to the antd
// ColumnsType<object> surface the Table expects here.
columns={mappedColumns as unknown as ColumnsType<object>}
data={mappedRows}
size={size}
data-test="listview-table"
@@ -316,7 +324,9 @@ function TableCollection<T extends object>({
sortDirections={['ascend', 'descend', 'ascend']}
isPaginationSticky={isPaginationSticky}
showRowCount={showRowCount}
rowClassName={getRowClassName}
rowClassName={
getRowClassName as unknown as TableProps<object>['rowClassName']
}
expandable={expandable}
components={{
header: {
@@ -342,7 +352,7 @@ function TableCollection<T extends object>({
),
},
}}
onChange={handleTableChange}
onChange={handleTableChange as unknown as TableProps<object>['onChange']}
/>
);
}

View File

@@ -20,6 +20,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { Select } from '@superset-ui/core/components';
import type { LabeledValue } from '@superset-ui/core/components';
import { extendedDayjs } from '../../utils/dates';
import {
timezoneOptionsCache,
@@ -156,7 +157,16 @@ export default function TimezoneSelector({
onOpenChange={handleOpenChange}
value={selectValue}
options={timezoneOptions || []}
sortComparator={sortComparator}
// Forward-compat: TS 6.0 resolves sortComparator against antd's
// LabeledValue; our comparator only reads properties that always exist
// on TimezoneOption, so the broader shape is safe at runtime.
sortComparator={
sortComparator as unknown as (
a: LabeledValue,
b: LabeledValue,
search?: string,
) => number
}
loading={isLoadingOptions}
placeholder={isLoadingOptions ? t('Loading timezones...') : placeholder}
{...{ placement: 'topLeft', ...rest }}

View File

@@ -82,7 +82,11 @@ export default class SupersetClientClass {
unauthorizedHandler = undefined,
}: ClientConfig = {}) {
const url = new URL(`${protocol || 'https:'}//${host || 'localhost'}`);
this.appRoot = appRoot;
// Strip a trailing slash so the getUrl dedupe comparisons and the final
// `${this.appRoot}/${...}` build stay correct regardless of how the root
// was supplied. Mirrors normalizeBackendUrlString / AppRootMiddleware /
// LegacyPrefixRedirectMiddleware, which all rstrip the root.
this.appRoot = appRoot.replace(/\/$/, '');
this.host = url.host;
this.protocol = url.protocol as Protocol;
this.headers = { Accept: 'application/json', ...headers }; // defaulting accept to json
@@ -296,8 +300,26 @@ export default class SupersetClientClass {
const host = inputHost ?? this.host;
const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash
// Strip a single leading appRoot segment so callers that accidentally
// pre-prefix their endpoint (e.g. by wrapping with ensureAppRoot before
// passing to the client) do not produce a doubled `/superset/superset/...`
// URL. Single-pass strip mirrors
// `stripAppRoot` in `src/utils/pathUtils` and `normalizeBackendUrlString`
// exactly: a genuine `/superset/superset/<slug>` is a legitimate route, not
// a double-prefix bug. The L2 static invariant still flags pre-prefixing as
// a migration issue; this is the runtime safety net.
let cleanEndpoint = endpoint;
const root = this.appRoot;
if (root) {
if (cleanEndpoint === root) {
cleanEndpoint = '';
} else if (cleanEndpoint.startsWith(`${root}/`)) {
cleanEndpoint = cleanEndpoint.slice(root.length);
}
}
return `${this.protocol}//${cleanHost}${this.appRoot}/${
endpoint[0] === '/' ? endpoint.slice(1) : endpoint
cleanEndpoint[0] === '/' ? cleanEndpoint.slice(1) : cleanEndpoint
}`;
}
}

View File

@@ -55,24 +55,25 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
if (parseMethod === 'json-bigint') {
const rawData = await response.text();
const json = JSONbig.parse(rawData);
const decoded = cloneDeepWith(json, (value: any) => {
if (
value?.isInteger?.() === true &&
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
) {
// toFixed() avoids scientific notation, which BigInt() rejects.
return BigInt(value.toFixed());
}
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
// // TODO: clean up after json-bigint>1.0.1 is released
if (value?.isNaN?.() === false) {
return value?.toNumber?.();
}
return undefined;
});
const result: JsonResponse = {
response,
json: cloneDeepWith(json, (value: any) => {
if (
value?.isInteger?.() === true &&
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
) {
// toFixed() avoids scientific notation, which BigInt() rejects.
return BigInt(value.toFixed());
}
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
// // TODO: clean up after json-bigint>1.0.1 is released
if (value?.isNaN?.() === false) {
return value?.toNumber?.();
}
return undefined;
}),
json: decoded,
};
return result as ReturnType;
}

View File

@@ -21,6 +21,9 @@ export { default as callApi } from './callApi';
export { default as SupersetClient } from './SupersetClient';
export { default as SupersetClientClass } from './SupersetClientClass';
export { normalizeBackendUrlString } from './normalizeBackendUrls';
export type { NormalizeOptions } from './normalizeBackendUrls';
export * from './types';
export * from './constants';
export { default as __hack_reexport_connection } from './types';

View File

@@ -0,0 +1,59 @@
/**
* 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.
*/
/**
* Strips the configured application root from a single backend-supplied URL
* string so the frontend speaks router-relative paths. Apply it at the few call
* sites that surface a router-relative URL from an API response (e.g. a
* dataset's `explore_url`) before handing the value to a consumer that
* re-prefixes the root — `SupersetClient.getUrl`, `makeUrl`, or a react-router
* `<Link>` resolving against the Router `basename`. Without it those consumers
* would re-prefix an already-rooted path into `/superset/superset/...`.
*
* Absolute (`https:`, `ftp:`, `mailto:`, `tel:`) and protocol-relative (`//`)
* URLs pass through untouched, so an operator-configured external
* `default_endpoint` on a dataset is left alone.
*/
export interface NormalizeOptions {
/** Application root to strip. Empty string disables normalisation. */
applicationRoot: string;
}
const SAFE_ABSOLUTE_URL_RE = /^(?:https?|ftp|mailto|tel):/i;
function stripTrailingSlash(root: string): string {
return root.endsWith('/') ? root.slice(0, -1) : root;
}
/** Normalise a single router-relative URL string. */
export function normalizeBackendUrlString(
value: string,
options: NormalizeOptions,
): string {
const root = stripTrailingSlash(options.applicationRoot);
if (!root) return value;
if (SAFE_ABSOLUTE_URL_RE.test(value)) return value;
if (value.startsWith('//')) return value;
if (value === root) return '/';
if (value.startsWith(`${root}/`)) {
return value.slice(root.length);
}
return value;
}

View File

@@ -26,10 +26,18 @@ export type FetchRetryOptions = {
retries?: number;
retryDelay?:
| number
| ((attempt: number, error: Error, response: Response) => number);
| ((
attempt: number,
error: Error | null,
response: Response | null,
) => number);
retryOn?:
| number[]
| ((attempt: number, error: Error, response: Response) => boolean);
| ((
attempt: number,
error: Error | null,
response: Response | null,
) => boolean);
};
export type Headers = { [k: string]: string };
export type Host = string;

View File

@@ -26,6 +26,11 @@ import NumberFormats from '../number-format/NumberFormats';
import { Currency } from '../query';
import { RowData, RowDataValue } from './types';
import { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats';
import { getCurrencyLocale } from './currencyLocale';
import {
resolveSymbolPosition,
formatWithSymbolPosition,
} from './symbolPosition';
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
@@ -90,7 +95,7 @@ class CurrencyFormatter extends ExtensibleFunction {
);
this.d3Format = config.d3Format || NumberFormats.SMART_NUMBER;
this.currency = config.currency;
this.locale = config.locale || 'en-US';
this.locale = config.locale || getCurrencyLocale();
}
hasValidCurrency() {
@@ -128,13 +133,16 @@ class CurrencyFormatter extends ExtensibleFunction {
try {
const symbol = getCurrencySymbol({ symbol: normalizedCurrency });
if (symbol) {
if (this.currency.symbolPosition === 'prefix') {
return `${symbol} ${normalizedValue}`;
} else if (this.currency.symbolPosition === 'suffix') {
return `${normalizedValue} ${symbol}`;
}
// Unknown symbolPosition - default to suffix
return `${normalizedValue} ${symbol}`;
const position = resolveSymbolPosition(
normalizedCurrency,
this.currency.symbolPosition,
this.locale,
);
return formatWithSymbolPosition(
symbol,
normalizedValue,
position,
);
}
} catch {
// Invalid currency code - return value without currency symbol
@@ -147,13 +155,15 @@ class CurrencyFormatter extends ExtensibleFunction {
try {
const symbol = getCurrencySymbol(this.currency);
if (this.currency.symbolPosition === 'prefix') {
return `${symbol} ${normalizedValue}`;
} else if (this.currency.symbolPosition === 'suffix') {
return `${normalizedValue} ${symbol}`;
if (!symbol) {
return formattedValue;
}
// Unknown symbolPosition - default to suffix
return `${normalizedValue} ${symbol}`;
const position = resolveSymbolPosition(
this.currency.symbol,
this.currency.symbolPosition,
this.locale,
);
return formatWithSymbolPosition(symbol, normalizedValue, position);
} catch {
// Invalid currency code - return value without currency symbol
return formattedValue;

View File

@@ -0,0 +1,52 @@
/**
* 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.
*/
const DEFAULT_CURRENCY_LOCALE = 'en-US';
let currencyLocale: string = DEFAULT_CURRENCY_LOCALE;
/**
* Set the locale used to resolve the default currency symbol position.
*
* Called once at application bootstrap with the deployment locale so that
* currency formatting follows the conventions of that locale (e.g. EUR is a
* suffix in `fr-FR`/`de-DE` but a prefix in `en-US`).
*
* Superset's bootstrap locale can be underscore-formatted (e.g. `zh_TW`,
* `pt_BR`), but `Intl.NumberFormat` expects BCP-47 tags with hyphens. The
* value is canonicalized before storing so symbol resolution does not throw
* and silently fall back. Empty or invalid tags leave the locale unchanged.
*/
export function setCurrencyLocale(locale?: string): void {
if (!locale) {
return;
}
try {
// getCanonicalLocales throws on a malformed tag and otherwise returns a
// non-empty list, so the first entry is always a valid canonical tag here.
[currencyLocale] = Intl.getCanonicalLocales(locale.replace(/_/g, '-'));
} catch {
// Invalid locale tag — keep the previously configured locale.
}
}
/** Get the locale used to resolve the default currency symbol position. */
export function getCurrencyLocale(): string {
return currencyLocale;
}

View File

@@ -24,5 +24,11 @@ export {
hasMixedCurrencies,
} from './CurrencyFormatter';
export { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats';
export { getCurrencyLocale, setCurrencyLocale } from './currencyLocale';
export {
resolveSymbolPosition,
formatWithSymbolPosition,
type SymbolPosition,
} from './symbolPosition';
export * from './types';
export * from './utils';

View File

@@ -0,0 +1,93 @@
/**
* 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 { getCurrencyLocale } from './currencyLocale';
export type SymbolPosition = 'prefix' | 'suffix';
const NUMERIC_PART_TYPES = new Set<Intl.NumberFormatPartTypes>([
'integer',
'group',
'decimal',
'fraction',
]);
/**
* Memoize resolved positions by `(locale, currencyCode)`. `format` runs on a
* hot per-value path (every currency cell of every chart), so avoid rebuilding
* an `Intl.NumberFormat` and re-parsing the locale for repeated values.
*/
const positionCache = new Map<string, SymbolPosition>();
/**
* Resolve where the currency symbol should be placed relative to the value.
*
* An explicit `prefix`/`suffix` is always honored. When the position is unset,
* it is derived from the locale's own convention for that currency via
* `Intl.NumberFormat` (e.g. `$1` in `en-US` is a prefix, `1 €` in `fr-FR` is a
* suffix). Unknown currency codes fall back to `prefix`, the most common
* convention worldwide.
*/
export function resolveSymbolPosition(
currencyCode: string | undefined,
symbolPosition?: string,
locale: string = getCurrencyLocale(),
): SymbolPosition {
if (symbolPosition === 'prefix' || symbolPosition === 'suffix') {
return symbolPosition;
}
if (currencyCode) {
const cacheKey = `${locale}|${currencyCode}`;
const cached = positionCache.get(cacheKey);
if (cached) {
return cached;
}
try {
const parts = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).formatToParts(1);
const currencyIndex = parts.findIndex(part => part.type === 'currency');
const valueIndex = parts.findIndex(part =>
NUMERIC_PART_TYPES.has(part.type),
);
if (currencyIndex !== -1 && valueIndex !== -1) {
const position = currencyIndex < valueIndex ? 'prefix' : 'suffix';
positionCache.set(cacheKey, position);
return position;
}
} catch {
// Unknown currency or locale — fall back to the default below.
}
}
return 'prefix';
}
/**
* Combine a symbol and a formatted value according to the resolved position.
*/
export function formatWithSymbolPosition(
symbol: string,
value: string,
position: SymbolPosition,
): string {
return position === 'prefix' ? `${symbol} ${value}` : `${value} ${symbol}`;
}

View File

@@ -32,7 +32,7 @@ export default function getDatasourceMetadata({
}: Params) {
return client
.get({
endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
...requestConfig,
})
.then(response => response.json as Datasource);

View File

@@ -106,6 +106,7 @@ export type ChartCustomization = {
};
description?: string;
removed?: boolean;
time_grains?: string[];
};
export type ChartCustomizationDivider = Partial<

View File

@@ -103,10 +103,16 @@ export const fetchTimeRange = async (
),
),
};
} catch (response) {
} catch (caught) {
// Forward-compat: TS 6.0 types caught values as `unknown`; cast to the
// shape getClientErrorObject accepts and narrow for statusText access.
const response = caught as Parameters<typeof getClientErrorObject>[0];
const clientError = await getClientErrorObject(response);
return {
error: clientError.message || clientError.error || response.statusText,
error:
clientError.message ||
clientError.error ||
(response as { statusText?: string }).statusText,
};
}
};

View File

@@ -55,7 +55,12 @@ class LRUCache<T> {
throw new TypeError('The LRUCache key must be string.');
}
if (this.cache.size >= this.capacity) {
this.cache.delete(this.cache.keys().next().value);
// Forward-compat: TS 6.0 types IteratorResult.value as `string | undefined`
// when not explicitly checked; guard before passing to Map#delete.
const oldestKey = this.cache.keys().next().value;
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, value);
}

View File

@@ -283,6 +283,22 @@ test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
}
});
test('relative raster tile templates do not receive OSM attribution', () => {
// A host-relative template cannot be parsed by `new URL`, so the OSM
// hostname check must fall through to "not OSM" rather than throw.
const relativeTileUrl = '/local-tiles/{z}/{x}/{y}.png';
const style = resolveMapStyle(
`tile://${relativeTileUrl}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([relativeTileUrl]);
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
}
});
test('style JSON URLs pass through without raster wrapping', () => {
const styleUrl = 'https://example.com/styles/custom-style.json';

View File

@@ -88,6 +88,9 @@ type BootstrapData = {
};
export function getBootstrapDataFromDocument(): unknown {
/* istanbul ignore if -- a missing document only occurs in SSR/worker
contexts, which Jest cannot simulate: jsdom pins `document` as a
non-configurable global */
if (typeof document === 'undefined') {
return undefined;
}

View File

@@ -176,7 +176,9 @@ describe('ChartClient', () => {
Promise.reject(new Error('Unexpected all to v1 API')),
);
fetchMock.post('glob:*/superset/explore_json/', {
// post `Superset.route_base = ""`, the legacy endpoint
// collapsed from `/superset/explore_json/` to `/explore_json/`.
fetchMock.post('glob:*/explore_json/', {
field1: 'abc',
field2: 'def',
});
@@ -198,13 +200,10 @@ describe('ChartClient', () => {
describe('.loadDatasource(datasourceKey, options)', () => {
test('fetches datasource', () => {
fetchMock.get(
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
{
field1: 'abc',
field2: 'def',
},
);
fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
field1: 'abc',
field2: 'def',
});
return expect(chartClient.loadDatasource('1__table')).resolves.toEqual({
field1: 'abc',
@@ -264,13 +263,10 @@ describe('ChartClient', () => {
color: 'living-coral',
});
fetchMock.get(
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
{
name: 'transactions',
schema: 'staging',
},
);
fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
name: 'transactions',
schema: 'staging',
});
fetchMock.post('glob:*/api/v1/chart/data', {
lorem: 'ipsum',

View File

@@ -0,0 +1,113 @@
/**
* 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 { SupersetClientClass } from '@superset-ui/core';
// SupersetClient is expected to apply the configured appRoot exactly once.
// Callers must pass router-relative endpoints; pre-prefixing causes the
// double-prefix bug documented below.
describe('SupersetClient applies the application root exactly once', () => {
const buildClient = () =>
new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});
test('endpoint without leading slash is concatenated correctly', () => {
expect(buildClient().getUrl({ endpoint: 'api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});
test('endpoint with leading slash is normalised to a single root segment', () => {
expect(buildClient().getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});
// A trailing slash on the configured appRoot is stripped at construction
// (SupersetClientClass `appRoot.replace(/\/$/, '')`). Without it, a root of
// '/superset/' produced 'https://host/superset//foo', and the dedupe block's
// `startsWith('/superset//')` check silently failed to dedupe a pre-prefixed
// endpoint. This pins both behaviours against regression.
test('trailing-slash appRoot is normalised to a single root segment', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset/',
});
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
// and a pre-prefixed endpoint is still deduped, not doubled
expect(client.getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});
// Runtime safety net: if a caller pre-prefixes the endpoint (e.g. by wrapping
// with ensureAppRoot before calling), getUrl strips the duplicate. The L2
// static invariant still flags the pattern at the call site — this guards
// against the bug reaching production if the static check is bypassed.
test('dedupes a leading application-root segment from a pre-prefixed endpoint', () => {
expect(buildClient().getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});
// Single-pass strip preserves a legitimate `/superset/superset/<slug>`
// route. Backend-supplied router-relative URLs are stripped of the root at
// the call sites that surface them (via `normalizeBackendUrlString`) before
// any re-prefixing helper sees them, so a doubled leading segment reaching
// `getUrl` is a real route, not a double-prefix bug. This pin guards against
// silent regression to a greedy strip.
test('strips exactly one application-root segment (single-pass)', () => {
expect(
buildClient().getUrl({ endpoint: '/superset/superset/api/v1/chart' }),
).toBe('https://config_host/superset/superset/api/v1/chart');
expect(
buildClient().getUrl({
endpoint: '/superset/superset/superset/api/v1/chart',
}),
).toBe('https://config_host/superset/superset/superset/api/v1/chart');
});
test('dedupe is segment-boundary aware — `/supersetfoo` is not a prefix match', () => {
expect(buildClient().getUrl({ endpoint: '/supersetfoo/x' })).toBe(
'https://config_host/superset/supersetfoo/x',
);
});
test('dedupes the bare application root to an empty endpoint', () => {
expect(buildClient().getUrl({ endpoint: '/superset' })).toBe(
'https://config_host/superset/',
);
});
test('empty application root produces no prefix segment', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
});
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/api/v1/chart',
);
});
});

View File

@@ -0,0 +1,89 @@
/**
* 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 { normalizeBackendUrlString } from '../../src/connection/normalizeBackendUrls';
const PREFIX = '/superset';
describe('normalizeBackendUrlString', () => {
test('strips application root from a router-relative path', () => {
expect(
normalizeBackendUrlString('/superset/explore/?slice_id=1', {
applicationRoot: PREFIX,
}),
).toBe('/explore/?slice_id=1');
});
test('strips a value that equals the application root exactly', () => {
expect(
normalizeBackendUrlString('/superset', { applicationRoot: PREFIX }),
).toBe('/');
});
test('tolerates a trailing slash on applicationRoot', () => {
expect(
normalizeBackendUrlString('/superset/foo', {
applicationRoot: '/superset/',
}),
).toBe('/foo');
});
// The negative cases below prove the helper is conservative: it doesn't
// mutate external URLs or path segments that merely share text with the root.
test('passes absolute URLs through unchanged', () => {
expect(
normalizeBackendUrlString('https://external.example.com/superset/foo', {
applicationRoot: PREFIX,
}),
).toBe('https://external.example.com/superset/foo');
});
test('passes protocol-relative URLs through unchanged', () => {
expect(
normalizeBackendUrlString('//cdn.example.com/superset/foo', {
applicationRoot: PREFIX,
}),
).toBe('//cdn.example.com/superset/foo');
});
test('does not strip a similar-but-different prefix segment', () => {
// /superset-public/... shares text with /superset but is a different path
// segment. Only /superset followed by / or end-of-string counts.
expect(
normalizeBackendUrlString('/superset-public/explore/?slice_id=1', {
applicationRoot: PREFIX,
}),
).toBe('/superset-public/explore/?slice_id=1');
});
test('is a no-op when application root is empty', () => {
expect(
normalizeBackendUrlString('/superset/explore/?slice_id=1', {
applicationRoot: '',
}),
).toBe('/superset/explore/?slice_id=1');
});
test('is idempotent: normalize(normalize(x)) === normalize(x)', () => {
const once = normalizeBackendUrlString('/superset/explore/?id=1', {
applicationRoot: PREFIX,
});
const twice = normalizeBackendUrlString(once, { applicationRoot: PREFIX });
expect(twice).toBe(once);
});
});

View File

@@ -21,8 +21,14 @@ import {
CurrencyFormatter,
getCurrencySymbol,
NumberFormats,
setCurrencyLocale,
} from '@superset-ui/core';
afterEach(() => {
// Guard against any test mutating the shared currency locale singleton.
setCurrencyLocale('en-US');
});
test('getCurrencySymbol', () => {
expect(
getCurrencySymbol({ symbol: 'PLN', symbolPosition: 'prefix' }),
@@ -132,7 +138,9 @@ test('CurrencyFormatter:format', () => {
// @ts-expect-error
currency: { symbol: 'USD' },
});
expect(currencyFormatterWithoutPosition(VALUE)).toEqual('56.1M $');
// With no explicit position, placement follows the locale convention.
// USD is a prefix in the default en-US locale.
expect(currencyFormatterWithoutPosition(VALUE)).toEqual('$ 56.1M');
// @ts-expect-error
const currencyFormatterWithoutCurrency = new CurrencyFormatter({});
@@ -200,17 +208,29 @@ test('CurrencyFormatter AUTO mode uses suffix position from row context', () =>
expect(result).toMatch(/1,000\.00.*€/);
});
test('CurrencyFormatter AUTO mode uses default suffix when symbolPosition is unknown', () => {
const formatter = new CurrencyFormatter({
test('CurrencyFormatter AUTO mode resolves position from locale when symbolPosition is unset', () => {
// Default en-US locale: EUR symbol is a prefix.
const enFormatter = new CurrencyFormatter({
// @ts-expect-error
currency: { symbol: 'AUTO' },
d3Format: ',.2f',
});
const row = { currency: 'EUR' };
const result = formatter.format(1000, row, 'currency');
expect(result).toContain('€');
expect(result).toMatch(/1,000\.00.*€/);
const enResult = enFormatter.format(1000, row, 'currency');
expect(enResult).toContain('€');
expect(enResult).toMatch(/€.*1,000\.00/);
// fr-FR locale: EUR symbol is a suffix.
const frFormatter = new CurrencyFormatter({
// @ts-expect-error
currency: { symbol: 'AUTO' },
d3Format: ',.2f',
locale: 'fr-FR',
});
const frResult = frFormatter.format(1000, row, 'currency');
expect(frResult).toContain('€');
expect(frResult).toMatch(/1,000\.00.*€/);
});
test('CurrencyFormatter AUTO mode returns plain value when row currency is not a string (line 52)', () => {
@@ -265,3 +285,23 @@ test('CurrencyFormatter AUTO mode falls back to plain value when getCurrencySymb
expect(result).toBe('1,000.00');
});
test('CurrencyFormatter static mode returns plain value when getCurrencySymbol returns undefined', () => {
const formatter = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
d3Format: ',.2f',
});
const OrigNumberFormat = Intl.NumberFormat;
// formatToParts without a 'currency' entry → getCurrencySymbol returns
// undefined, exercising the `if (!symbol)` guard in the static branch.
Intl.NumberFormat = jest.fn().mockImplementation(() => ({
formatToParts: () => [{ type: 'integer', value: '1' }],
})) as unknown as typeof Intl.NumberFormat;
const result = formatter.format(1000);
Intl.NumberFormat = OrigNumberFormat;
expect(result).toBe('1,000.00');
});

View File

@@ -0,0 +1,65 @@
/*
* 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 {
getCurrencyLocale,
setCurrencyLocale,
resolveSymbolPosition,
} from '@superset-ui/core';
afterEach(() => {
// Restore the default so other tests are not affected by the global locale.
setCurrencyLocale('en-US');
});
test('currency locale defaults to en-US', () => {
expect(getCurrencyLocale()).toEqual('en-US');
});
test('setCurrencyLocale updates the locale used to resolve unset positions', () => {
setCurrencyLocale('fr-FR');
expect(getCurrencyLocale()).toEqual('fr-FR');
// EUR is a suffix in fr-FR.
expect(resolveSymbolPosition('EUR')).toEqual('suffix');
});
test('setCurrencyLocale ignores empty values', () => {
setCurrencyLocale('de-DE');
setCurrencyLocale(undefined);
setCurrencyLocale('');
expect(getCurrencyLocale()).toEqual('de-DE');
});
test('setCurrencyLocale canonicalizes underscore-formatted locales to BCP-47', () => {
// Superset bootstrap can emit underscore tags like `pt_BR`/`zh_TW`.
setCurrencyLocale('pt_BR');
expect(getCurrencyLocale()).toEqual('pt-BR');
// BRL is a prefix in pt-BR; the placement must resolve instead of throwing
// and falling back.
expect(resolveSymbolPosition('BRL')).toEqual('prefix');
setCurrencyLocale('zh_TW');
expect(getCurrencyLocale()).toEqual('zh-TW');
});
test('setCurrencyLocale keeps the previous locale for invalid tags', () => {
setCurrencyLocale('fr-FR');
setCurrencyLocale('not a locale!');
expect(getCurrencyLocale()).toEqual('fr-FR');
});

View File

@@ -0,0 +1,74 @@
/*
* 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 {
resolveSymbolPosition,
formatWithSymbolPosition,
} from '@superset-ui/core';
test('resolveSymbolPosition honors an explicit position regardless of locale', () => {
expect(resolveSymbolPosition('EUR', 'prefix', 'fr-FR')).toEqual('prefix');
expect(resolveSymbolPosition('USD', 'suffix', 'en-US')).toEqual('suffix');
});
test('resolveSymbolPosition derives the position from the locale when unset', () => {
// en-US places the symbol before the value for these currencies.
expect(resolveSymbolPosition('USD', undefined, 'en-US')).toEqual('prefix');
expect(resolveSymbolPosition('GBP', undefined, 'en-US')).toEqual('prefix');
expect(resolveSymbolPosition('EUR', undefined, 'en-US')).toEqual('prefix');
// Eurozone locales place the EUR symbol after the value.
expect(resolveSymbolPosition('EUR', undefined, 'fr-FR')).toEqual('suffix');
expect(resolveSymbolPosition('EUR', undefined, 'de-DE')).toEqual('suffix');
});
test('resolveSymbolPosition returns the same result on repeated calls (cached)', () => {
// The second call hits the memoized (locale, currencyCode) entry.
expect(resolveSymbolPosition('EUR', undefined, 'fr-FR')).toEqual('suffix');
expect(resolveSymbolPosition('EUR', undefined, 'fr-FR')).toEqual('suffix');
});
test('resolveSymbolPosition falls back to prefix for unknown currencies', () => {
expect(resolveSymbolPosition('INVALID_CODE', undefined, 'en-US')).toEqual(
'prefix',
);
expect(resolveSymbolPosition(undefined, undefined, 'en-US')).toEqual(
'prefix',
);
});
test('resolveSymbolPosition falls back to prefix when locale parts lack a currency', () => {
const OrigNumberFormat = Intl.NumberFormat;
// formatToParts without a 'currency' part → currencyIndex is -1, so the
// position cannot be derived and the default prefix is returned. Use a
// locale/currency pair not exercised elsewhere so the memoization cache
// does not short-circuit this call.
Intl.NumberFormat = jest.fn().mockImplementation(() => ({
formatToParts: () => [{ type: 'integer', value: '1' }],
})) as unknown as typeof Intl.NumberFormat;
expect(resolveSymbolPosition('USD', undefined, 'zz-mock')).toEqual('prefix');
Intl.NumberFormat = OrigNumberFormat;
});
test('formatWithSymbolPosition places the symbol according to the position', () => {
expect(formatWithSymbolPosition('$', '1,000', 'prefix')).toEqual('$ 1,000');
expect(formatWithSymbolPosition('€', '1,000', 'suffix')).toEqual('1,000 €');
});

View File

@@ -35,8 +35,10 @@ describe('getFormData()', () => {
field2: 'def',
};
// post-`route_base=""`, the legacy endpoint collapsed
// from `/superset/fetch_datasource_metadata` to `/fetch_datasource_metadata`.
fetchMock.get(
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
'glob:*/fetch_datasource_metadata?datasourceKey=1__table',
mockData,
);

View File

@@ -21,3 +21,4 @@ declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.css';

View File

@@ -44,7 +44,7 @@ export class DashboardPage {
* @param slug - The dashboard slug (e.g., 'world_health')
*/
async gotoBySlug(slug: string): Promise<void> {
await gotoWithRetry(this.page, `superset/dashboard/${slug}/`);
await gotoWithRetry(this.page, `dashboard/${slug}/`);
}
/**
@@ -52,7 +52,7 @@ export class DashboardPage {
* @param id - The dashboard ID
*/
async gotoById(id: number): Promise<void> {
await gotoWithRetry(this.page, `superset/dashboard/${id}/`);
await gotoWithRetry(this.page, `dashboard/${id}/`);
}
/**

View File

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

View File

@@ -35,5 +35,5 @@ export const URL = {
LOGIN: 'login/',
SAVED_QUERIES_LIST: 'savedqueryview/list/',
SQLLAB: 'sqllab',
WELCOME: 'superset/welcome/',
WELCOME: 'welcome/',
} as const;

View File

@@ -1573,6 +1573,47 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
expect(xAxis.triggerEvent).toBe(true);
});
test('temporal x coltype forced categorical yields a Category axis with date labels', () => {
// Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time
// scale places ticks at "nice" intervals that don't line up with the buckets.
// Forcing categorical maps each bucket to a discrete, tick-aligned category
// while still formatting the labels as dates rather than raw timestamps.
const ts1 = 1745784000000;
const ts2 = 1745870400000;
const chartProps = createTestChartProps({
formData: {
metrics: ['metric'],
granularity_sqla: 'ds',
x_axis: '__timestamp',
xAxisForceCategorical: true,
},
queriesData: [
createTestQueryData(
[
{ __timestamp: ts1, metric: 10 },
{ __timestamp: ts2, metric: 20 },
],
{
colnames: ['__timestamp', 'metric'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
),
],
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as {
type: string;
axisLabel: { formatter: (v: Date) => string };
};
expect(xAxis.type).toBe(AxisType.Category);
const label = xAxis.axisLabel.formatter(new Date(ts1));
expect(typeof label).toBe('string');
expect(label).not.toMatch(/NaN/);
expect(label).not.toBe(String(ts1));
});
test('temporal x coltype wires the time formatter and Time axis', () => {
// Regression guard: the happy path for time-series charts. Ensures that
// Temporal coltype keeps routing through the TimeFormatter so a refactor

View File

@@ -26,6 +26,9 @@ import luminanceFromRGB from '../utils/luminanceFromRGB';
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
export const isValidCanvasRadius = (value: number) =>
Number.isFinite(value) && value > 0;
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
@@ -352,8 +355,11 @@ function ScatterPlotOverlay({
: String(pointMetric);
}
if (!pointRadius) {
if (!isValidCanvasRadius(pointRadius)) {
pointRadius = defaultRadius;
if (pointMetric === null) {
pointLabel = undefined;
}
}
ctx.arc(

View File

@@ -18,8 +18,8 @@
*/
import { render } from '@testing-library/react';
import ScatterPlotOverlay from '../src/components/ScatterPlotOverlay';
import {
import ScatterPlotOverlay, {
isValidCanvasRadius,
MIN_CLUSTER_RADIUS_RATIO,
MAX_POINT_RADIUS_RATIO,
} from '../src/components/ScatterPlotOverlay';
@@ -158,6 +158,18 @@ const MIN_VISIBLE_POINT_RADIUS =
const MAX_VISIBLE_POINT_RADIUS =
defaultProps.dotRadius * MAX_POINT_RADIUS_RATIO;
test.each([
[1, true],
[0.1, true],
[0, false],
[-1, false],
[NaN, false],
[Infinity, false],
[-Infinity, false],
])('validates canvas radius value %p', (value, expected) => {
expect(isValidCanvasRadius(value)).toBe(expected);
});
test('renders map with varying radius values in Pixels mode', () => {
const locations = [
createLocation([100, 100], { radius: 10, cluster: false }),
@@ -373,6 +385,42 @@ test('renders map with Miles mode', () => {
}).not.toThrow();
});
test.each(['Kilometers', 'Miles'])(
'falls back to default radius for non-positive %s values',
pointRadiusUnit => {
const locations = [
createLocation([100, 50], { radius: -5, cluster: false }),
createLocation([200, 50], { radius: 0, cluster: false }),
createLocation([300, 50], { radius: 10, cluster: false }),
];
render(
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit={pointRadiusUnit}
zoom={10}
/>,
);
const redrawParams = triggerRedraw();
const arcCalls = redrawParams.ctx.arc.mock.calls;
arcCalls.forEach(call => {
expect(Number.isFinite(call[2])).toBe(true);
expect(call[2]).toBeGreaterThan(0);
});
expect(arcCalls[0][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
expect(arcCalls[1][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
expect(arcCalls[2][2]).toBeGreaterThan(MIN_VISIBLE_POINT_RADIUS);
const expectedLabel = pointRadiusUnit === 'Miles' ? '10mi' : '10km';
expect(redrawParams.ctx.fillText.mock.calls.map(call => call[0])).toEqual([
expectedLabel,
]);
},
);
test('displays metric property labels on points', () => {
const locations = [
createLocation([100, 100], { radius: 50, metric: 123.456, cluster: false }),

View File

@@ -56,6 +56,7 @@ import { PAGE_SIZE_OPTIONS } from '../consts';
import { sortAlphanumericCaseInsensitive } from './utils/sortAlphanumericCaseInsensitive';
import { SearchOption, SortByItem } from '../types';
import SearchSelectDropdown from './components/SearchSelectDropdown';
import { SupersetTheme, css } from '@apache-superset/core/theme';
export interface DataTableProps<D extends object> extends TableOptions<D> {
tableClassName?: string;
@@ -561,6 +562,9 @@ export default typedMemo(function DataTable<D extends object>({
align="center"
justify="space-between"
gap="middle"
css={(theme: SupersetTheme) => css`
font-size: ${theme.fontSizeSM}px;
`}
>
{hasPagination ? (
<SelectPageSize
@@ -576,30 +580,32 @@ export default typedMemo(function DataTable<D extends object>({
/>
) : null}
<Flex wrap align="center" gap="middle">
{serverPagination && searchInput && (
<Space size="small" className="search-select-container">
<span className="search-by-label">{t('Search by')}:</span>
<SearchSelectDropdown
searchOptions={searchOptions}
value={serverPaginationData?.searchColumn || ''}
onChange={onSearchColChange}
/>
</Space>
)}
{searchInput && (
<GlobalFilter<D>
searchInput={
typeof searchInput === 'boolean' ? undefined : searchInput
}
preGlobalFilteredRows={preGlobalFilteredRows}
setGlobalFilter={
manualSearch ? handleSearchChange : setGlobalFilter
}
filterValue={manualSearch ? initialSearchText : filterValue}
id={searchInputId}
serverPagination={!!serverPagination}
rowCount={rowCount}
/>
<>
{serverPagination && (
<Space direction="vertical" size={4}>
{t('Search by')}
<SearchSelectDropdown
searchOptions={searchOptions}
value={serverPaginationData?.searchColumn || ''}
onChange={onSearchColChange}
/>
</Space>
)}
<GlobalFilter<D>
searchInput={
typeof searchInput === 'boolean' ? undefined : searchInput
}
preGlobalFilteredRows={preGlobalFilteredRows}
setGlobalFilter={
manualSearch ? handleSearchChange : setGlobalFilter
}
filterValue={manualSearch ? initialSearchText : filterValue}
id={searchInputId}
serverPagination={!!serverPagination}
rowCount={rowCount}
/>
</>
)}
{renderTimeComparisonDropdown
? renderTimeComparisonDropdown()

View File

@@ -17,14 +17,9 @@
* under the License.
*/
/* eslint-disable import/no-extraneous-dependencies */
import { styled } from '@apache-superset/core/theme';
import { RawAntdSelect } from '@superset-ui/core/components';
import { RawAntdSelect as Select } from '@superset-ui/core/components';
import { SearchOption } from '../../types';
const StyledSelect = styled(RawAntdSelect)`
width: 120px;
margin-right: 8px;
`;
import { SupersetTheme, css } from '@apache-superset/core/theme';
interface SearchSelectDropdownProps {
/** The currently selected search column value */
@@ -41,10 +36,14 @@ function SearchSelectDropdown({
searchOptions,
}: SearchSelectDropdownProps) {
return (
<StyledSelect
<Select
className="search-select"
value={value || (searchOptions?.[0]?.value ?? '')}
css={(theme: SupersetTheme) => css`
width: ${theme.sizeUnit * 30}px;
`}
value={value ?? searchOptions?.[0]?.value}
options={searchOptions}
size="small"
onChange={onChange}
/>
);

View File

@@ -58,7 +58,7 @@ import {
useTheme,
SupersetTheme,
} from '@apache-superset/core/theme';
import { t, tn } from '@apache-superset/core/translation';
import { t } from '@apache-superset/core/translation';
import { GenericDataType } from '@apache-superset/core/common';
import {
Input,
@@ -254,12 +254,13 @@ function SearchInput({
inputRef,
}: SearchInputProps) {
return (
<Space direction="horizontal" size={4} className="dt-global-filter">
{t('Search')}
<Space direction="vertical" size={4} className="dt-global-filter">
<span aria-hidden="true">{t('Search')}</span>
<Input
aria-label={t('Search %s records', count)}
placeholder={tn('%s record', '%s records...', count, count)}
aria-label={t('Search records')}
placeholder={t('Search records')}
value={value}
size="small"
onChange={onChange}
onBlur={onBlur}
ref={inputRef}
@@ -276,18 +277,18 @@ function SelectPageSize({
const { Option } = Select;
return (
<span className="dt-select-page-size">
<Space direction="vertical" size={4} className="dt-select-page-size">
<VisuallyHidden htmlFor="pageSizeSelect">
{t('Select page size')}
</VisuallyHidden>
{t('Show')}{' '}
{t('Entries per page')}
<Select<number>
id="pageSizeSelect"
value={current}
onChange={value => onChange(value)}
size="small"
css={(theme: SupersetTheme) => css`
width: ${theme.sizeUnit * 18}px;
width: ${theme.sizeUnit * 30}px;
`}
aria-label={t('Show entries per page')}
>
@@ -301,9 +302,8 @@ function SelectPageSize({
</Option>
);
})}
</Select>{' '}
{t('entries per page')}
</span>
</Select>
</Space>
);
}

View File

@@ -2106,7 +2106,7 @@ describe('plugin-chart-table', () => {
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('Michael');
expect(screen.getByLabelText('Search 0 records')).toHaveValue(
expect(screen.getByLabelText('Search records')).toHaveValue(
'Michael',
);
});

View File

@@ -0,0 +1,183 @@
/**
* 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 { readdirSync, readFileSync, statSync } from 'fs';
import { join, relative, resolve, sep } from 'path';
const DEFAULT_ROOTS = ['src', 'packages/superset-ui-core/src'];
const ALWAYS_SKIP_SEGMENTS = new Set([
'node_modules',
'dist',
'build',
'coverage',
'__mocks__',
'cypress-base',
'playwright',
]);
const ALWAYS_SKIP_SUFFIXES = [
'.test.ts',
'.test.tsx',
'.stories.ts',
'.stories.tsx',
];
const SOURCE_EXTENSIONS = ['.ts', '.tsx'];
export interface ScanOptions {
/** Workspace-relative directories to scan. Defaults to the source tree. */
roots?: string[];
/** Extra path segments to skip on top of {@link ALWAYS_SKIP_SEGMENTS}. */
ignoreSegments?: string[];
/** Regex run against each line of each file. */
pattern: RegExp;
/** Workspace-relative paths (forward slashes) exempt from this scan. */
allowlist?: string[];
}
export interface ScanHit {
/** Workspace-relative path with forward slashes. */
file: string;
/** 1-based line number. */
line: number;
/** The text of the matching line, trimmed. */
text: string;
/** The substring captured by `pattern`. */
match: string;
}
// __dirname resolves to <workspace>/spec/helpers regardless of cwd.
const WORKSPACE_ROOT = resolve(__dirname, '..', '..');
function isSourceFile(name: string): boolean {
return (
SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) &&
!ALWAYS_SKIP_SUFFIXES.some(suffix => name.endsWith(suffix))
);
}
function walk(directory: string, ignoreSegments: Set<string>): string[] {
const found: string[] = [];
let entries;
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch {
return found;
}
for (const entry of entries) {
if (ignoreSegments.has(entry.name)) continue;
const absolute = join(directory, entry.name);
if (entry.isDirectory()) {
found.push(...walk(absolute, ignoreSegments));
} else if (entry.isFile() && isSourceFile(entry.name)) {
found.push(absolute);
}
}
return found;
}
function toForwardSlashes(path: string): string {
return sep === '/' ? path : path.split(sep).join('/');
}
/**
* Line-by-line regex scan over the source tree. Returns one {@link ScanHit}
* per matching line. Textual (not AST-based) — false positives on string
* literals should be fixed by tightening the regex.
*/
export function scanSource(options: ScanOptions): ScanHit[] {
const {
roots = DEFAULT_ROOTS,
ignoreSegments = [],
pattern,
allowlist = [],
} = options;
const ignoreSet = new Set([...ALWAYS_SKIP_SEGMENTS, ...ignoreSegments]);
const allowSet = new Set(allowlist);
const hits: ScanHit[] = [];
const seen = new Set<string>();
for (const root of roots) {
const absoluteRoot = resolve(WORKSPACE_ROOT, root);
let stat;
try {
stat = statSync(absoluteRoot);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
for (const absoluteFile of walk(absoluteRoot, ignoreSet)) {
if (seen.has(absoluteFile)) continue;
seen.add(absoluteFile);
const relativePath = toForwardSlashes(
relative(WORKSPACE_ROOT, absoluteFile),
);
if (allowSet.has(relativePath)) continue;
const contents = readFileSync(absoluteFile, 'utf8');
const lines = contents.split('\n');
// Reuse the regex per file. Without the `g` flag, `.exec` ignores
// lastIndex, so recompiling per-line was wasted allocation.
const lineRegex = pattern.flags.includes('g')
? new RegExp(pattern.source, pattern.flags.replace('g', ''))
: pattern;
for (let index = 0; index < lines.length; index += 1) {
const lineText = lines[index];
const match = lineRegex.exec(lineText);
if (match) {
hits.push({
file: relativePath,
line: index + 1,
text: lineText.trim(),
match: match[0],
});
}
}
}
}
return hits;
}
/** Format hits as a multi-line failure message: ` file:line — text`. */
export function formatHits(hits: ScanHit[], header: string): string {
if (hits.length === 0) return header;
const lines = hits
.slice(0, 50)
.map(hit => ` ${hit.file}:${hit.line}${hit.text}`);
const overflow =
hits.length > 50 ? `\n ... and ${hits.length - 50} more` : '';
return `${header}\n${lines.join('\n')}${overflow}`;
}
/** Throw with a formatted message if `hits` is non-empty. */
export function expectNoHits(hits: ScanHit[], header: string): void {
if (hits.length > 0) {
throw new Error(formatHits(hits, header));
}
}

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.
*/
/**
* Run `callback` with `getBootstrapData().common.application_root` set to
* `applicationRoot`. Resets modules so any imports inside the callback see
* the configured value, then restores the prior DOM and module cache on exit.
* Pass `''` to simulate the default root-of-domain deployment.
*/
export async function withApplicationRoot<T>(
applicationRoot: string,
callback: () => Promise<T> | T,
): Promise<T> {
const previousBody = document.body.innerHTML;
try {
const bootstrapData = { common: { application_root: applicationRoot } };
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(bootstrapData)}'></div>`;
jest.resetModules();
await import('src/utils/getBootstrapData');
return await callback();
} finally {
document.body.innerHTML = previousBody;
jest.resetModules();
}
}
/** Run `body` once per scenario, each under a different application root. */
export async function applicationRootScenarios<S extends { root: string }>(
scenarios: S[],
body: (scenario: S) => Promise<void> | void,
): Promise<void> {
for (const scenario of scenarios) {
// eslint-disable-next-line no-await-in-loop -- intentional: scenarios share document state.
await withApplicationRoot(scenario.root, () => body(scenario));
}
}

View File

@@ -20,6 +20,11 @@
import { Global } from '@emotion/react';
import { css } from '@apache-superset/core/theme';
// Class applied to the SQL Lab tab bar's overflow ("...") dropdown so its menu
// items truncate long tab names. The dropdown is portaled to the body, outside
// the tabs' emotion scope, so it is styled here via a global rule.
export const SQLLAB_TAB_OVERFLOW_POPUP_CLASS = 'sqllab-tab-overflow-popup';
export const SqlLabGlobalStyles = () => (
<Global
styles={theme => css`
@@ -30,6 +35,31 @@ export const SqlLabGlobalStyles = () => (
); // Set a min height so the gutter is always visible when resizing
overflow: hidden;
}
// The tab label is a flex node (icon menu + title + status icon). antd's
// overflow dropdown styles each menu item for a plain-text label, so the
// nested flex defeats its ellipsis and very long names render blank. Cap
// the item width and let the title truncate inside it.
.${SQLLAB_TAB_OVERFLOW_POPUP_CLASS} {
.ant-tabs-dropdown-menu-item {
max-width: ${theme.sizeUnit * 80}px;
}
.ant-tabs-dropdown-menu-item > span {
min-width: 0;
overflow: hidden;
}
[data-test='sql-editor-tab-header'] {
min-width: 0;
width: 100%;
}
[data-test='sql-editor-tab-title'] {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
`}
/>
);

View File

@@ -44,7 +44,7 @@ import {
import { fDuration, extendedDayjs } from '@superset-ui/core/utils/dates';
import { SqlLabRootState } from 'src/SqlLab/types';
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
import { makeUrl } from 'src/utils/pathUtils';
import { openInNewTab } from 'src/utils/navigationUtils';
import ResultSet from '../ResultSet';
import HighlightedSql from '../HighlightedSql';
import { StaticPosition, StyledTooltip, ModalResultSetWrapper } from './styles';
@@ -80,8 +80,7 @@ interface QueryTableProps {
}
const openQuery = (id: number) => {
const url = makeUrl(`/sqllab?queryId=${id}`);
window.open(url);
openInNewTab(`/sqllab?queryId=${id}`);
};
const QueryTable = ({

View File

@@ -53,7 +53,28 @@ jest.mock('@superset-ui/core', () => ({
isFeatureEnabled: jest.fn().mockReturnValue(false),
}));
// Mock openInNewTab so the Create-chart "new window" branch can be asserted
// without spawning a real window. The rest of navigationUtils stays real so
// existing CSV-download tests keep using the genuine `redirect`/`makeUrl`.
jest.mock('src/utils/navigationUtils', () => ({
...jest.requireActual('src/utils/navigationUtils'),
openInNewTab: jest.fn(),
}));
// eslint-disable-next-line import/order, import/first
import { openInNewTab } from 'src/utils/navigationUtils';
// Stub postFormData so the Create-chart click resolves quickly; this lets
// the test focus on the URL composition that happens after the resolve.
jest.mock('src/explore/exploreUtils/formData', () => ({
...jest.requireActual('src/explore/exploreUtils/formData'),
postFormData: jest.fn(),
}));
// eslint-disable-next-line import/order, import/first
import { postFormData } from 'src/explore/exploreUtils/formData';
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const mockOpenInNewTab = openInNewTab as jest.Mock;
const mockPostFormData = postFormData as jest.Mock;
jest.mock('src/components/ErrorMessage', () => ({
ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
@@ -160,6 +181,9 @@ describe('ResultSet', () => {
beforeEach(() => {
applicationRootMock.mockReturnValue('');
mockStartExport.mockClear();
mockOpenInNewTab.mockClear();
mockPostFormData.mockReset();
mockPostFormData.mockResolvedValue('test-form-data-key');
});
// Add cleanup after each test
@@ -1009,4 +1033,103 @@ describe('ResultSet', () => {
screen.getByRole('button', { name: 'Results Action' }),
).toBeInTheDocument();
});
test('Create chart in new window opens single-prefixed explore URL under subdirectory deployment', async () => {
// When the user metaKey-clicks "Create chart", the SQL-Lab result handoff
// composes an explore URL via mountExploreUrl(..., includeAppRoot=true).
// Under SUPERSET_APP_ROOT=/superset, the resulting URL must contain the
// prefix exactly once. A doubled prefix (/superset/superset/explore/…)
// produces a blank Explore page.
const appRoot = '/superset';
applicationRootMock.mockReturnValue(appRoot);
const queryWithId = {
...queries[0],
results: {
...queries[0].results,
query_id: 42,
},
};
const { getByTestId } = setup(
{
...mockedProps,
queryId: queryWithId.id,
database: { allows_subquery: true, allows_virtual_table_explore: true },
},
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queryWithId.id]: queryWithId,
},
},
}),
);
const exploreButton = await waitFor(() =>
getByTestId('explore-results-button'),
);
fireEvent.click(exploreButton, { metaKey: true });
await waitFor(() => {
expect(mockOpenInNewTab).toHaveBeenCalledTimes(1);
});
const url = mockOpenInNewTab.mock.calls[0][0] as string;
expect(url).toMatch(/^\/superset\/explore\/\?.*form_data_key=/);
expect(url).not.toMatch(/\/superset\/superset\//);
});
test('Create chart in same window pushes router-relative explore URL under subdirectory deployment', async () => {
// Same-tab click (no metaKey) goes through history.push under the SPA
// basename Router, so mountExploreUrl is called with includeAppRoot=false.
// The composed URL must NOT carry an app-root prefix — the router applies
// it once via <Router basename={applicationRoot()}>. A premature prefix
// here would compound with the basename and yield /superset/superset/…
const appRoot = '/superset';
applicationRootMock.mockReturnValue(appRoot);
const queryWithId = {
...queries[0],
results: {
...queries[0].results,
query_id: 99,
},
};
const store = mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queryWithId.id]: queryWithId,
},
},
});
const { getByTestId } = render(
<ResultSet
{...mockedProps}
queryId={queryWithId.id}
database={{
allows_subquery: true,
allows_virtual_table_explore: true,
}}
/>,
{ useRedux: true, store, useRouter: true },
);
const exploreButton = await waitFor(() =>
getByTestId('explore-results-button'),
);
fireEvent.click(exploreButton);
await waitFor(() => {
expect(mockPostFormData).toHaveBeenCalledTimes(1);
});
expect(mockOpenInNewTab).not.toHaveBeenCalled();
});
});

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import {
useCallback,
useEffect,
@@ -88,7 +87,7 @@ import { usePermissions } from 'src/hooks/usePermissions';
import { StreamingExportModal } from 'src/components/StreamingExportModal';
import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
import { useConfirmModal } from 'src/hooks/useConfirmModal';
import { makeUrl } from 'src/utils/pathUtils';
import { makeUrl, openInNewTab, redirect } from 'src/utils/navigationUtils';
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql';
@@ -312,7 +311,9 @@ const ResultSet = ({
includeAppRoot,
);
if (openInNewWindow) {
window.open(url, '_blank', 'noreferrer');
// `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
// helper re-applies `ensureAppRoot` idempotently.
openInNewTab(url);
} else {
history.push(url);
}
@@ -379,7 +380,13 @@ const ResultSet = ({
{ rows: rowsCount.toLocaleString() },
),
onConfirm: () => {
window.location.href = sanitizeUrl(getExportCsvUrl(query.id));
// `getExportCsvUrl` already runs the path through `makeUrl`;
// `redirect` re-applies `ensureAppRoot` idempotently and routes
// the sink through navigationUtils' barriers (scheme allowlist,
// userinfo rejection, backslash rejection), which is a
// strict superset of what `sanitizeUrl` from master PR #40546
// provides.
redirect(getExportCsvUrl(query.id));
},
confirmText: t('OK'),
cancelText: t('Close'),

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import { useCallback, useState, FormEvent } from 'react';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio';
@@ -58,6 +57,7 @@ import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants';
import { isEmpty } from 'lodash-es';
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
interface QueryDatabase {
id?: number;
@@ -244,10 +244,16 @@ export const SaveDatasetModal = ({
useState(false);
const createWindow = (url: string) => {
// `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
// navigationUtils helpers re-apply `ensureAppRoot` idempotently.
if (openWindow) {
window.open(sanitizeUrl(url), '_blank', 'noreferrer');
// `openInNewTab` / `redirect` route the sink through navigationUtils'
// barriers (scheme allowlist, userinfo rejection, backslash
// rejection) — strictly stronger than master PR #40546's `sanitizeUrl`
// wrap, which only rejects `javascript:` / `data:` / `vbscript:`.
openInNewTab(url);
} else {
window.location.href = sanitizeUrl(url);
redirect(url);
}
};
const formDataWithDefaults = {

View File

@@ -55,306 +55,303 @@ const setup = (queryEditor: QueryEditor, store?: Store) =>
...(store && { store }),
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('SqlEditorTabHeader', () => {
test('renders name', () => {
const { queryByText } = setup(defaultQueryEditor, mockStore(initialState));
expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument();
expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument();
});
// Renders the header and opens its "..." dropdown menu, returning the store so
// each test can assert on the actions it dispatches.
const openTabDropdown = () => {
const store = mockStore(initialState);
const { getByTestId } = setup(defaultQueryEditor, store);
userEvent.click(getByTestId('dropdown-trigger'));
return store;
};
test('renders name from unsaved changes', () => {
const expectedTitle = 'updated title';
const { queryByText } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
name: expectedTitle,
},
},
}),
);
expect(queryByText(expectedTitle)).toBeInTheDocument();
expect(queryByText(defaultQueryEditor.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument();
});
test('renders current name for unrelated unsaved changes', () => {
const unrelatedTitle = 'updated title';
const { queryByText } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: `${defaultQueryEditor.id}-other`,
name: unrelatedTitle,
},
},
}),
);
expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument();
expect(queryByText(unrelatedTitle)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('with dropdown menus', () => {
let store = mockStore();
beforeEach(async () => {
store = mockStore(initialState);
const { getByTestId } = setup(defaultQueryEditor, store);
const dropdown = getByTestId('dropdown-trigger');
userEvent.click(dropdown);
});
test('should dispatch removeQueryEditor action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('close-tab-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: REMOVE_QUERY_EDITOR,
queryEditor: defaultQueryEditor,
}),
);
});
test('should dispatch queryEditorSetTitle action', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
const expectedTitle = 'typed text';
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: expectedTitle } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: QUERY_EDITOR_SET_TITLE,
name: expectedTitle,
queryEditor: expect.objectContaining({
id: defaultQueryEditor.id,
}),
}),
);
});
test('prefills the rename input with the current tab name', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
expect(input).toHaveValue(defaultQueryEditor.name);
});
test('focuses the rename input when the modal opens', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
await waitFor(() => expect(input).toHaveFocus());
});
test('disables Save when the input is empty or whitespace', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
test('does not dispatch or dismiss on Enter when the input is empty', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, charCode: 13 });
const dispatchedTitleChange = store
.getActions()
.some(action => action.type === QUERY_EDITOR_SET_TITLE);
expect(dispatchedTitleChange).toBe(false);
// the modal must stay open so the user can correct the name,
// mirroring the disabled Save button rather than dismissing like Escape
expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
test('does not dispatch a title change when the modal is cancelled', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(store.getActions()).toEqual([]);
});
test('does not dispatch a title change when dismissed with the close button', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByTestId('close-modal-btn'));
expect(store.getActions()).toEqual([]);
});
test('returns focus to the tab header after the modal is cancelled', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
await screen.findByTestId('rename-tab-input');
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('returns focus to the tab header after a successful rename', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'renamed tab' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('should dispatch removeAllOtherQueryEditors action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('close-all-other-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions).toEqual([
{
type: REMOVE_QUERY_EDITOR,
queryEditor: initialState.sqlLab.queryEditors[1],
},
{
type: REMOVE_QUERY_EDITOR,
queryEditor: initialState.sqlLab.queryEditors[2],
},
]),
);
});
test('should dispatch cloneQueryToNewTab action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('clone-tab-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: ADD_QUERY_EDITOR,
queryEditor: expect.objectContaining({
name: `Copy of ${defaultQueryEditor.name}`,
sql: defaultQueryEditor.sql,
autorun: false,
}),
}),
);
});
});
test('does not leak tab-editing keystrokes from the rename input to the surrounding tabs', async () => {
const onContainerKeyDown = jest.fn();
const store = mockStore(initialState);
render(
<div onKeyDown={onContainerKeyDown}>
<SqlEditorTabHeader queryEditor={defaultQueryEditor} />
</div>,
{ useRedux: true, store },
);
userEvent.click(screen.getByTestId('dropdown-trigger'));
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
// The modal portals over the editable-card tabs, whose keyboard handler would
// otherwise remove, navigate, or activate a tab (and swallow Space). None of
// these keys should escape the modal to the surrounding container.
[
'Delete',
'Backspace',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
' ',
].forEach(key => fireEvent.keyDown(input, { key }));
expect(onContainerKeyDown).not.toHaveBeenCalled();
// Escape (close) and Tab (focus trap) must still reach the Modal.
fireEvent.keyDown(input, { key: 'Tab' });
fireEvent.keyDown(input, { key: 'Escape' });
const reached = onContainerKeyDown.mock.calls.map(call => call[0].key);
expect(reached).toEqual(expect.arrayContaining(['Tab', 'Escape']));
});
test('renders name', () => {
const { queryByText } = setup(defaultQueryEditor, mockStore(initialState));
expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument();
expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument();
});
test('exposes the name on a dedicated node the overflow dropdown can truncate', () => {
// The overflow ("...") menu reuses this label and styles the title node by
// its data-test to keep very long names from rendering blank.
const { getByTestId } = setup(defaultQueryEditor, mockStore(initialState));
expect(getByTestId('sql-editor-tab-title')).toHaveTextContent(
defaultQueryEditor.name,
);
});
test('renders name from unsaved changes', () => {
const expectedTitle = 'updated title';
const { queryByText } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
name: expectedTitle,
},
},
}),
);
expect(queryByText(expectedTitle)).toBeInTheDocument();
expect(queryByText(defaultQueryEditor.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument();
});
test('renders current name for unrelated unsaved changes', () => {
const unrelatedTitle = 'updated title';
const { queryByText } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: `${defaultQueryEditor.id}-other`,
name: unrelatedTitle,
},
},
}),
);
expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument();
expect(queryByText(unrelatedTitle)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument();
expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument();
});
test('should dispatch removeQueryEditor action', async () => {
const store = openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('close-tab-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: REMOVE_QUERY_EDITOR,
queryEditor: defaultQueryEditor,
}),
);
});
test('should dispatch queryEditorSetTitle action', async () => {
const store = openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
const expectedTitle = 'typed text';
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: expectedTitle } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: QUERY_EDITOR_SET_TITLE,
name: expectedTitle,
queryEditor: expect.objectContaining({
id: defaultQueryEditor.id,
}),
}),
);
});
test('prefills the rename input with the current tab name', async () => {
openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
expect(input).toHaveValue(defaultQueryEditor.name);
});
test('focuses the rename input when the modal opens', async () => {
openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
await waitFor(() => expect(input).toHaveFocus());
});
test('disables Save when the input is empty or whitespace', async () => {
openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
test('does not dispatch or dismiss on Enter when the input is empty', async () => {
const store = openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, charCode: 13 });
const dispatchedTitleChange = store
.getActions()
.some(action => action.type === QUERY_EDITOR_SET_TITLE);
expect(dispatchedTitleChange).toBe(false);
// the modal must stay open so the user can correct the name,
// mirroring the disabled Save button rather than dismissing like Escape
expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
test('does not dispatch a title change when the modal is cancelled', async () => {
const store = openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(store.getActions()).toEqual([]);
});
test('does not dispatch a title change when dismissed with the close button', async () => {
const store = openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByTestId('close-modal-btn'));
expect(store.getActions()).toEqual([]);
});
test('returns focus to the tab header after the modal is cancelled', async () => {
openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
await screen.findByTestId('rename-tab-input');
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('returns focus to the tab header after a successful rename', async () => {
openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'renamed tab' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('should dispatch removeAllOtherQueryEditors action', async () => {
const store = openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('close-all-other-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions).toEqual([
{
type: REMOVE_QUERY_EDITOR,
queryEditor: initialState.sqlLab.queryEditors[1],
},
{
type: REMOVE_QUERY_EDITOR,
queryEditor: initialState.sqlLab.queryEditors[2],
},
]),
);
});
test('should dispatch cloneQueryToNewTab action', async () => {
const store = openTabDropdown();
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('clone-tab-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: ADD_QUERY_EDITOR,
queryEditor: expect.objectContaining({
name: `Copy of ${defaultQueryEditor.name}`,
sql: defaultQueryEditor.sql,
autorun: false,
}),
}),
);
});
test('does not leak tab-editing keystrokes from the rename input to the surrounding tabs', async () => {
const onContainerKeyDown = jest.fn();
const store = mockStore(initialState);
render(
<div onKeyDown={onContainerKeyDown}>
<SqlEditorTabHeader queryEditor={defaultQueryEditor} />
</div>,
{ useRedux: true, store },
);
userEvent.click(screen.getByTestId('dropdown-trigger'));
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
// The modal portals over the editable-card tabs, whose keyboard handler would
// otherwise remove, navigate, or activate a tab (and swallow Space). None of
// these keys should escape the modal to the surrounding container.
[
'Delete',
'Backspace',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
' ',
].forEach(key => fireEvent.keyDown(input, { key }));
expect(onContainerKeyDown).not.toHaveBeenCalled();
// Escape (close) and Tab (focus trap) must still reach the Modal.
fireEvent.keyDown(input, { key: 'Tab' });
fireEvent.keyDown(input, { key: 'Escape' });
const reached = onContainerKeyDown.mock.calls.map(call => call[0].key);
expect(reached).toEqual(expect.arrayContaining(['Tab', 'Escape']));
});

View File

@@ -245,7 +245,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
/>
}
/>
<TabTitle>{qe.name}</TabTitle>{' '}
<TabTitle data-test="sql-editor-tab-title">{qe.name}</TabTitle>{' '}
<StatusIcon
className="status-icon"
iconSize="m"

View File

@@ -29,6 +29,7 @@ import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { detectOS } from 'src/utils/common';
import * as Actions from 'src/SqlLab/actions/sqlLab';
import { Icons } from '@superset-ui/core/components/Icons';
import { SQLLAB_TAB_OVERFLOW_POPUP_CLASS } from 'src/SqlLab/SqlLabGlobalStyles';
import SqlEditor from '../SqlEditor';
import SqlEditorTabHeader from '../SqlEditorTabHeader';
@@ -262,6 +263,7 @@ function TabbedSqlEditors({
hideAdd={offline}
onTabClick={onTabClicked}
onEdit={handleEdit}
popupClassName={SQLLAB_TAB_OVERFLOW_POPUP_CLASS}
type={queryEditors?.length === 0 ? 'card' : 'editable-card'}
addIcon={
<Tooltip

View File

@@ -151,7 +151,7 @@ describe('table actions', () => {
fetchMock.callHistory.calls(getTableMetadataEndpoint),
).toHaveLength(1),
);
const refreshButton = getByRole('button', { name: 'sync' });
const refreshButton = getByRole('button', { name: 'Refresh table schema' });
fireEvent.click(refreshButton);
await waitFor(() =>
expect(
@@ -170,7 +170,9 @@ describe('table actions', () => {
fetchMock.callHistory.calls(getTableMetadataEndpoint),
).toHaveLength(1),
);
const viewButton = getByRole('button', { name: 'eye' });
const viewButton = getByRole('button', {
name: 'Show CREATE VIEW statement',
});
fireEvent.click(viewButton);
await waitFor(() =>
expect(

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.
*/
import { ChartCustomization, ChartCustomizationType } from '@superset-ui/core';
import { getInitialDataMask } from 'src/dataMask/reducer';
import type { ChartCustomizationsFormItem } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
import { transformCustomizationForSave } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/transformers';
const createTimeGrainCustomizationInput = (
timeGrains?: string[],
): Omit<ChartCustomizationsFormItem, 'type'> => ({
scope: {
rootPath: ['ROOT_ID'],
excluded: [],
},
name: 'Time Grain Display Control',
filterType: 'chart_customization_timegrain',
dataset: {
value: 10,
label: 'main.dataset',
},
column: 'dttm',
controlValues: {},
requiredFirst: {},
defaultValue: null,
defaultDataMask: getInitialDataMask(),
sortMetric: null,
time_grains: timeGrains,
description: '',
});
test('transformCustomizationForSave persists time_grains when a subset is selected', () => {
const transformed = transformCustomizationForSave(
'CHART_CUSTOMIZATION-subset',
createTimeGrainCustomizationInput([
'PT1H',
'P1D',
'P1W',
]) as ChartCustomizationsFormItem,
);
expect(transformed).toBeDefined();
expect(transformed?.type).toBe(ChartCustomizationType.ChartCustomization);
expect(transformed && 'time_grains' in transformed).toBe(true);
expect(
transformed && 'time_grains' in transformed
? transformed.time_grains
: undefined,
).toEqual(['PT1H', 'P1D', 'P1W']);
});
test('transformCustomizationForSave omits time_grains from API payload when all are selected', () => {
const transformed = transformCustomizationForSave(
'CHART_CUSTOMIZATION-all',
createTimeGrainCustomizationInput(undefined) as ChartCustomizationsFormItem,
);
expect(transformed).toBeDefined();
expect(
transformed && 'time_grains' in transformed
? transformed.time_grains
: undefined,
).toBeUndefined();
// API boundary: undefined keys are omitted from JSON payloads.
const serialized = JSON.parse(JSON.stringify(transformed));
expect(serialized).not.toHaveProperty('time_grains');
});
test('transformCustomizationForSave remains backward compatible when time_grains is missing', () => {
const formInput = createTimeGrainCustomizationInput();
delete (formInput as Partial<ChartCustomizationsFormItem>).time_grains;
const transformed = transformCustomizationForSave(
'CHART_CUSTOMIZATION-legacy',
formInput as ChartCustomizationsFormItem,
);
expect(transformed).toBeDefined();
expect(
transformed && 'time_grains' in transformed
? transformed.time_grains
: undefined,
).toBeUndefined();
const serialized = JSON.parse(JSON.stringify(transformed));
expect(serialized).not.toHaveProperty('time_grains');
});
test('transformCustomizationForSave omits time_grains when an empty array is provided', () => {
const transformed = transformCustomizationForSave(
'CHART_CUSTOMIZATION-empty-array',
createTimeGrainCustomizationInput([]) as ChartCustomizationsFormItem,
);
expect(transformed).toBeDefined();
expect(
transformed && 'time_grains' in transformed
? transformed.time_grains
: undefined,
).toBeUndefined();
// API boundary: empty allowlist should behave like unrestricted and be omitted.
const serialized = JSON.parse(JSON.stringify(transformed));
expect(serialized).not.toHaveProperty('time_grains');
});
test('transformCustomizationForSave preserves saved time_grains when reloading an existing customization', () => {
// Simulates the edit round-trip: a previously saved ChartCustomization is
// passed back through the transformer. The allowlist must survive intact so
// the edit modal can restore it.
const savedCustomization: ChartCustomization = {
id: 'CHART_CUSTOMIZATION-existing',
type: ChartCustomizationType.ChartCustomization,
name: 'Time Grain Display Control',
filterType: 'chart_customization_timegrain',
description: '',
targets: [{ datasetId: 10, column: { name: 'dttm' } }],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: {},
defaultDataMask: getInitialDataMask(),
removed: false,
time_grains: ['PT1H', 'P1D'],
};
const transformed = transformCustomizationForSave(
savedCustomization.id,
savedCustomization,
);
expect(transformed).toBeDefined();
expect(
transformed && 'time_grains' in transformed
? transformed.time_grains
: undefined,
).toEqual(['PT1H', 'P1D']);
});

View File

@@ -97,11 +97,6 @@ export default function PluginFilterTimegrain(
handleChange(filterState.value ?? []);
}, [JSON.stringify(filterState.value)]);
const placeholderText =
(data || []).length === 0
? t('No data')
: tn('%s option', '%s options', data.length, data.length);
const formItemData: FormItemProps = {};
if (filterState.validateMessage) {
formItemData.extra = (
@@ -111,15 +106,27 @@ export default function PluginFilterTimegrain(
);
}
const options = (data || []).map(
(row: { name: string; duration: string }) => {
const options = (data || [])
.map((row: { name: string; duration: string }) => {
const { name, duration } = row;
return {
label: name,
value: duration,
};
},
);
})
// Apply allowlist filter if timeGrains is configured, but keep current selection visible
.filter(option => {
const allowlist = formData.timeGrains;
if (!allowlist || allowlist.length === 0) {
return true;
}
return allowlist.includes(option.value) || value.includes(option.value);
});
const placeholderText =
options.length === 0
? t('No data')
: tn('%s option', '%s options', options.length, options.length);
const sortComparator = useCallback(
(a: LabeledValue, b: LabeledValue) => {

View File

@@ -0,0 +1,181 @@
/**
* 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.
*/
/**
* Integration Test: Time Grain Pre-filter Feature (Customization plugin)
*
* Mirrors src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx
* but targets the customization plugin at
* src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin and adds
* a case for the escape hatch the customization version introduces.
*/
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import PluginFilterTimegrain from 'src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin';
import type { PluginFilterTimeGrainProps } from 'src/chartCustomizations/components/TimeGrain/types';
const data = [
{ duration: 'PT1M', name: 'Minute' },
{ duration: 'PT1H', name: 'Hour' },
{ duration: 'P1D', name: 'Day' },
{ duration: 'P1W', name: 'Week' },
{ duration: 'P1M', name: 'Month' },
];
const baseConfig = {
height: 100,
width: 300,
setFilterActive: jest.fn(),
setHoveredFilter: jest.fn(),
unsetHoveredFilter: jest.fn(),
setFocusedFilter: jest.fn(),
unsetFocusedFilter: jest.fn(),
inputRef: { current: null },
};
/**
* Scenario: Dashboard owner configures a time grain filter to show only Hour, Day, Week.
* End-user opens the dashboard and can only select from those three options.
*/
test('time grain pre-filter restricts dashboard filter options', async () => {
const setDataMask = jest.fn();
const dashboardConfig: PluginFilterTimeGrainProps = {
...baseConfig,
data,
formData: {
datasource: '3__table',
height: 100,
width: 300,
nativeFilterId: 'time_grain_1',
defaultValue: null,
viz_type: 'filter_timegrain',
timeGrains: ['PT1H', 'P1D', 'P1W'],
},
filterState: {
value: null,
validateStatus: undefined,
validateMessage: undefined,
},
setDataMask,
};
render(<PluginFilterTimegrain {...dashboardConfig} />);
expect(screen.getByText('3 options')).toBeInTheDocument();
setDataMask.mockClear();
await userEvent.click(screen.getByRole('combobox'));
await waitFor(() => {
const labels = screen.getAllByRole('option').map(o => o.textContent);
expect(labels).toEqual(['Hour', 'Day', 'Week']);
expect(labels).not.toContain('Minute');
expect(labels).not.toContain('Month');
});
await userEvent.click(screen.getByText('Day'));
await waitFor(() => {
expect(setDataMask).toHaveBeenCalledWith({
extraFormData: { time_grain_sqla: 'P1D' },
filterState: { label: 'Day', value: ['P1D'] },
});
});
});
/**
* Scenario: Dashboard owner disables pre-filter (unchecks the CollapsibleControl).
* No restrictions: all time grains appear in the runtime filter.
*/
test('all time grains appear when pre-filter is unchecked', async () => {
const dashboardConfig: PluginFilterTimeGrainProps = {
...baseConfig,
data,
formData: {
datasource: '3__table',
height: 100,
width: 300,
nativeFilterId: 'time_grain_1',
defaultValue: null,
viz_type: 'filter_timegrain',
timeGrains: undefined,
},
filterState: {
value: null,
validateStatus: undefined,
validateMessage: undefined,
},
setDataMask: jest.fn(),
};
render(<PluginFilterTimegrain {...dashboardConfig} />);
await userEvent.click(screen.getByRole('combobox'));
await waitFor(() => {
const labels = screen.getAllByRole('option').map(o => o.textContent);
expect(labels).toEqual(['Minute', 'Hour', 'Day', 'Week', 'Month']);
});
});
/**
* Scenario: Dashboard owner narrowed the pre-filter after an end-user had
* already selected a value that now falls outside the allowlist.
* The current selection stays visible so the filter does not silently drop it.
*/
test('current selection stays visible when it is outside the pre-filter allowlist', async () => {
const dashboardConfig: PluginFilterTimeGrainProps = {
...baseConfig,
data,
formData: {
datasource: '3__table',
height: 100,
width: 300,
nativeFilterId: 'time_grain_1',
defaultValue: null,
viz_type: 'filter_timegrain',
timeGrains: ['PT1H', 'P1D', 'P1W'],
},
filterState: {
value: ['P1M'],
validateStatus: undefined,
validateMessage: undefined,
},
setDataMask: jest.fn(),
};
render(<PluginFilterTimegrain {...dashboardConfig} />);
await userEvent.click(screen.getByRole('combobox'));
await waitFor(() => {
const labels = screen.getAllByRole('option').map(o => o.textContent);
expect(labels).toHaveLength(4);
expect(labels).toEqual(
expect.arrayContaining(['Hour', 'Day', 'Week', 'Month']),
);
expect(labels).not.toContain('Minute');
});
});

View File

@@ -24,6 +24,7 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
interface PluginFilterTimeGrainCustomizeProps {
defaultValue?: string[] | null;
inputRef?: RefObject<HTMLInputElement>;
timeGrains?: string[];
}
export type PluginFilterTimeGrainQueryFormData = QueryFormData &

View File

@@ -84,7 +84,7 @@ import {
} from 'src/database/actions';
import Mousetrap from 'mousetrap';
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
import { makeUrl } from 'src/utils/pathUtils';
import { makeUrl, openInNewTab } from 'src/utils/navigationUtils';
import {
OwnerSelectLabel,
OWNER_TEXT_LABEL_PROP,
@@ -1181,7 +1181,9 @@ function DatasourceEditor({
}, [datasource]);
const openOnSqlLab = useCallback(() => {
window.open(getSQLLabUrl(), '_blank', 'noopener,noreferrer');
// `getSQLLabUrl()` already runs the path through `makeUrl`; `openInNewTab`
// re-applies `ensureAppRoot`, which is idempotent on already-prefixed paths.
openInNewTab(getSQLLabUrl());
}, [getSQLLabUrl]);
const onQueryRun = useCallback(async () => {

View File

@@ -66,7 +66,7 @@ test('renders single dashboard link correctly', () => {
const link = screen.getByText('Sales Dashboard');
expect(link).toBeInTheDocument();
expect(link.closest('a')).toHaveAttribute('href', '/superset/dashboard/1/');
expect(link.closest('a')).toHaveAttribute('href', '/dashboard/1/');
expect(link.closest('a')).toHaveAttribute('target', '_blank');
});
@@ -98,9 +98,9 @@ test('links have correct href attributes', () => {
.getByText(', Very Long Dashboard Name That Should Be Truncated')
.closest('a');
expect(salesLink).toHaveAttribute('href', '/superset/dashboard/1/');
expect(analyticsLink).toHaveAttribute('href', '/superset/dashboard/2/');
expect(longNameLink).toHaveAttribute('href', '/superset/dashboard/3/');
expect(salesLink).toHaveAttribute('href', '/dashboard/1/');
expect(analyticsLink).toHaveAttribute('href', '/dashboard/2/');
expect(longNameLink).toHaveAttribute('href', '/dashboard/3/');
});
test('applies correct styling classes', () => {
@@ -124,5 +124,5 @@ test('handles dashboard with empty title', () => {
const link = screen.getByRole('link');
expect(link).toHaveTextContent('');
expect(link).toHaveAttribute('href', '/superset/dashboard/1/');
expect(link).toHaveAttribute('href', '/dashboard/1/');
});

View File

@@ -62,7 +62,7 @@ const DashboardLinksExternal = ({
{dashboards.map((dashboard, index) => (
<GenericLink
key={dashboard.id}
to={`/superset/dashboard/${dashboard.id}/`}
to={`/dashboard/${dashboard.id}/`}
target="_blank"
>
{index === 0

View File

@@ -25,6 +25,7 @@ import {
within,
} from 'spec/helpers/testing-library';
import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
import * as getBootstrapData from 'src/utils/getBootstrapData';
import {
createProps,
DATASOURCE_ENDPOINT,
@@ -822,3 +823,57 @@ test('calculated column search is case-insensitive', async () => {
expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
});
});
test('Open in SQL lab href is single-prefixed under subdirectory deployment', () => {
// The Open-in-SQL-Lab link's href is produced by `getSQLLabUrl()`:
// return makeUrl(`/sqllab/?${queryParams.toString()}`);
// `makeUrl` is the idempotent app-root prefix helper from
// `src/utils/navigationUtils`. Rendering the link requires both the
// virtual datasourceType state AND a populated Redux `database.queryResult`
// slice (which is not part of the default test reducer tree). Calling
// `makeUrl` directly with a `/superset` mock exercises the exact path the
// component takes and pins the dedupe invariant for the underlying helper.
const applicationRootSpy = jest
.spyOn(getBootstrapData, 'applicationRoot')
.mockReturnValue('/superset');
try {
const { makeUrl } = jest.requireActual('src/utils/navigationUtils');
const queryParams = new URLSearchParams({
dbid: '1',
sql: 'SELECT * FROM users',
name: 'Vehicle Sales',
schema: 'public',
autorun: 'true',
isDataset: 'true',
});
const url = makeUrl(`/sqllab/?${queryParams.toString()}`);
expect(url).toMatch(/^\/superset\/sqllab\/\?/);
expect(url).not.toMatch(/\/superset\/superset\//);
} finally {
applicationRootSpy.mockRestore();
}
});
test('DatasourceEditor source pins getSQLLabUrl/openOnSqlLab to the makeUrl + openInNewTab helpers', () => {
// Source-pin: lock the exact two-line shape the runtime behaviour depends
// on. `getSQLLabUrl` MUST wrap its `/sqllab/?...` path in `makeUrl` so the
// Layer-2 idempotent prefix runs at the click boundary; `openOnSqlLab`
// MUST delegate to `openInNewTab` so `ensureAppRoot` runs again (idempotent
// dedupe, see `navigationUtils.appRoot.test.tsx`). A refactor that drops
// either layer would let a doubled-prefix URL escape into a new tab.
// eslint-disable-next-line global-require
const { readFileSync } = require('fs');
// eslint-disable-next-line global-require
const { join } = require('path');
const src = readFileSync(
join(__dirname, '..', 'DatasourceEditor.tsx'),
'utf8',
);
expect(src).toMatch(
/return makeUrl\(`\/sqllab\/\?\$\{queryParams\.toString\(\)\}`\);/,
);
expect(src).toMatch(/openInNewTab\(getSQLLabUrl\(\)\);/);
expect(src).toMatch(
/import \{ makeUrl, openInNewTab \} from 'src\/utils\/navigationUtils';/,
);
});

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