Compare commits

...

97 Commits

Author SHA1 Message Date
Evan
fed40796fd ci(mypy): add scripts/__init__.py to fix duplicate-module error
mypy was passed scripts/change_detector.py (resolved as top-level
module "change_detector") and the new test importing it as
"scripts.change_detector", causing a "Source file found twice under
different module names" error. Making scripts/ an explicit package
resolves the ambiguity. The script is still invoked path-based in CI
(python scripts/change_detector.py), so this is non-breaking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:50:42 -07:00
Evan
5a11cc2177 test(ci): add unit tests for change_detector workflow_run resolution
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:50:42 -07:00
Claude Code
39f93eb63c ci: gate Cypress/Playwright behind pre-commit via workflow_run
Make the E2E workflow run only after the "pre-commit checks" workflow
completes successfully, instead of in parallel with it. When a PR has
formatting/lint/type errors that pre-commit catches in ~4 minutes, the
Cypress shards and Playwright jobs (~15-20 min each, full setup) no longer
spin up only to be wasted.

Mechanism:
- Trigger switches from push/pull_request to `workflow_run` on "pre-commit
  checks" (which itself runs on push + pull_request, preserving coverage).
- The entry `changes` job gates on
  `github.event.workflow_run.conclusion == 'success'`; on pre-commit failure
  it (and every downstream `needs: changes` job) is skipped, provisioning no
  runners.
- change_detector.py learns a `workflow_run` event path: it recovers the
  originating event/SHA/PR from WF_RUN_* env vars (fork PRs lack PR context
  in the payload, so they conservatively assume-all-changed).
- Checkouts and the push-only /app/prefix matrix switch read
  `github.event.workflow_run.*` instead of the live event.
- A `report-status` job posts an aggregate "E2E / required" commit status
  back to the PR head SHA, since workflow_run checks don't attach to PRs
  automatically. This becomes the required check in branch protection.

This stacks on #40718 (the job-level `changes` gating) and also contains
those changes; the net-new here is the workflow_run gate plus the
change_detector workflow_run support.

Known tradeoffs (see PR description): GitHub uses the default-branch copy of
the workflow for workflow_run, so edits won't take effect until merged; fork
PRs run the full suite; branch protection must require "E2E / required".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:50:41 -07:00
Evan Rusackas
cf5307d0c6 ci: reduce Cypress parallelism from 6 shards to 2 (#40717)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 23:48:46 +02:00
Evan Rusackas
9d1bc6b2cc fix(i18n): don't flag intentional string deletions as translation regressions (#40716)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:47:31 -07:00
Shaitan
6a125bf774 fix(jinja): expose dialect-escaped companion value on get_filters() (#40531) 2026-06-03 21:53:12 +01:00
Shaitan
43fde2fb07 fix(charts): enforce DISALLOWED_SQL_FUNCTIONS and DISALLOWED_SQL_TABLES at chart-data execution (#40567)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 21:52:48 +01:00
dependabot[bot]
2be2246a00 chore(deps-dev): bump gevent from 24.2.1 to 26.4.0 (#40378)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Evan <evan@preset.io>
2026-06-03 12:58:17 -07:00
Evan Rusackas
80a5f6b787 fix(calendar): Fix day offset in Calendar Heatmap visualization (#34564)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Joe Li <joe@preset.io>
2026-06-03 12:46:12 -07:00
Evan Rusackas
c373da1bb9 ci: add cancel-in-progress concurrency to PR helper workflows (#40725)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 12:44:36 -07:00
Evan Rusackas
80ea36c852 fix(db_engine_specs): escape schema name in regex; document safe filter pattern (#40642)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 11:56:51 -07:00
Evan Rusackas
6ea4e22785 refactor(nvd3): extract testable generateAnnotationTooltipContent helper (#40620)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:56:07 -07:00
Evan Rusackas
fcb1e299ac fix(nvd3): sanitize generateMultiLineTooltipContent output (#40612)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 11:55:55 -07:00
Amin Ghadersohi
f4dfb7f026 fix(mcp): fall back to form_data spatial query for Deck.gl charts (#40339) 2026-06-03 13:30:52 -04:00
Amin Ghadersohi
001834470b fix(mcp): escape LIKE wildcards in MCP list tool search filters (#40682) 2026-06-03 13:30:05 -04:00
Evan Rusackas
e5c7200551 ci: gate expensive test workflows at the job level (#40718)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 10:04:10 -07:00
Evan Rusackas
cb2a56d16e chore: guard recursive merge keys and invoke subprocess without a shell (#40558)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 22:18:05 +07:00
Burhanuddin Mundrawala
e5ff6de790 chore: correct typos in config.py and models_test.py comments (#40706) 2026-06-03 21:58:29 +07:00
faisal2901
accc94da51 fix(users): show 0 for null login_count and fail_login_count (#40281) 2026-06-03 10:14:46 -04:00
Evan Rusackas
c914df5a67 ci: harden CI against Docker Hub registry flakes (retries + auth) (#40700)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:53:24 +07:00
Shaitan
e3ba85b1a5 fix(redirect): normalize browser-stripped whitespace before protocol-relative check (#40566)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:56:10 +01:00
Shaitan
b8a2f925ee fix(views): enforce per-chart access check in legacy form_data endpoint (#40497)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:56:03 +01:00
Shaitan
77c2bed5f7 fix(dashboards): narrow datasets payload to callers with read access (#40396)
Co-authored-by: Claude Sonnet 4 <noreply@anthropic.com>
2026-06-03 12:55:57 +01:00
Shaitan
56fd991efd fix(dataset): unify validation for stored and adhoc SQL expressions (#40392)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:55:50 +01:00
Shaitan
61b32d1b7d fix(chart): standardize dashboard validation across chart create/update (#40336)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:55:44 +01:00
Shaitan
3191b0fdcd fix: apply dashboard access check in related_objects endpoints (#40333)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:55:38 +01:00
Shaitan
cf08a5ebf7 feat(docker): add environment-based debugger control (#40327)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Jay Masiwal <masiwaljay.02@gmail.com>
Co-authored-by: JUST.in DO IT <justin.park@airbnb.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: chaselynisabella <chaselynisabella@gmail.com>
2026-06-03 12:55:31 +01:00
Shaitan
f7f50a7977 fix(sqllab): quote CTAS target identifiers and validate tmp_table_name format (#40245)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:55:25 +01:00
Shaitan
725f5ed2a9 fix(api): enforce per-object ownership validation in chart, dataset, and report commands (#39303)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:55:15 +01:00
Shaitan
faa76f6741 fix(embedding): add optional dataset allowlist to guest tokens (#39302)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:55:09 +01:00
Shaitan
8e4a460cc7 fix(charts): apply DISALLOWED_SQL_FUNCTIONS gate to adhoc expressions (#40568)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:52:22 +01:00
Evan Rusackas
b9dc9d722e fix(export): sanitize user-supplied CSV export filename (charts + SQL Lab) (#40632)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 00:14:48 -07:00
Evan Rusackas
fa41769a08 fix(embedded): enforce configured allowed domains for postMessage origin (#40629)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 22:58:30 -07:00
Evan Rusackas
df21fe6571 chore(mcp): return a generic error from the webdriver pool-stats endpoint (#40559)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 21:51:32 -07:00
Evan Rusackas
12bef03f4a fix(jinja): apply consistent escaping to url_param values from request args (#40633)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 21:23:48 -07:00
Evan Rusackas
0b9764aed5 fix(mcp): honor AUTH_ROLE_ADMIN and warn on permission-less protected tools (#40659)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 21:20:11 -07:00
Evan Rusackas
ac522ded1c fix(ssh-tunnel): validate server_address format (SSRF defense-in-depth) (#40665)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 21:19:24 -07:00
Evan Rusackas
c54990c861 fix(plugin-chart-ag-grid-table): validate filter values/operators in state converter (#40623)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 18:44:29 -07:00
Evan Rusackas
3bbb35e8a3 ci(bashlib): drop the dead bc-based NONCE (perf + reliability) (#40691)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:49:49 -07:00
Shaitan
a2a369cb5c fix(charts): sanitize tooltip HTML across nvd3, rose and partition plugins (#40502)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-02 16:45:38 -07:00
Evan Rusackas
9af6746dbe fix(models): HTML-escape data-controlled values in dashboard_link and Slice.icons (#40639)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 16:15:11 -07:00
Evan Rusackas
6abee0289b fix(reports): guard SUCCESS-state report execution against duplicate sends and stuck WORKING state (#40657)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 15:09:14 -07:00
Evan Rusackas
8c62f533d7 fix(core): restrict allowed CSS properties in sanitized HTML (#40627)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 14:24:00 -07:00
Oleg Ovcharuk
17d1a45bc9 feat(ydb): switch to native YDB sqlglot dialect (#40170) 2026-06-02 17:13:41 -04:00
Shaitan
6eaee211aa fix(sqllab): require dataset match for raw query access (#40409)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:50:27 +01:00
Evan Rusackas
3e589436fa fix(reports): sanitize error text in email notification template (#40641)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 13:40:10 -07:00
Evan Rusackas
a9df2c7e5e fix(mcp): address post-approval review feedback on auth logging PR #40646 (#40684)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:39:23 -07:00
Evan Rusackas
8508af3201 chore(key_value): prune expired entries from the key-value store (#40663)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
2026-06-02 12:36:32 -07:00
Evan Rusackas
49f3dbba73 fix(dashboard): address post-approval review feedback on #40528 (#40685)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:16:15 -07:00
Amin Ghadersohi
616c243278 fix(deps): revert joserfc JWT error migration — fastmcp still uses authlib (#40688) 2026-06-02 12:02:17 -07:00
Evan Rusackas
00dd31494d fix: sanitize URL sinks and trim sensitive log fields (#40546)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 11:52:02 -07:00
Evan Rusackas
b97d3ef520 fix(api,sql): use json_response in Api.query and log dialect fallback (#40644)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 11:48:46 -07:00
Evan Rusackas
4d2b10d916 chore(excel): strip document metadata from Excel exports (#40661)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 11:48:36 -07:00
SBIN2010
86fa5bb46f feat(table v2): agGridTableChart add row numer column (#39284)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-06-02 11:37:26 -07:00
Evan Rusackas
19c2b67d09 fix(websocket): validate last_id query param format (#40626)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 11:36:33 -07:00
Jean Massucatto
d2d46169bf fix(explore): tighten popover title-to-tabs spacing to 12px (#40410) 2026-06-02 11:30:27 -07:00
Jean Massucatto
1b8099811b fix(chart-list): sort by changed_on instead of last_saved_at (#39984) 2026-06-02 10:58:23 -07:00
Evan Rusackas
242c27a974 test(presto): 401 Unauthorized must surface as CONNECTION_ACCESS_DENIED_ERROR (#33554) (#40618)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:49:58 -07:00
Evan Rusackas
24422c8311 test(histogram): metric filters require aggregation in buildQuery (#30330) (#40617)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:49:43 -07:00
Evan Rusackas
1632b235ae fix(sqllab): surface stacktrace in SQL Lab error responses (#28248) (#40585)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:41:39 -07:00
Evan Rusackas
093b43c7a5 fix(exports,email,logs): csv formula escaping, subject CRLF stripping, UTC log pruning (#40645)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 18:32:36 +01:00
Durgaprasad M L
4996d7c277 fix: avoid warning spam when default spinner SVG is missing (#40481)
Co-authored-by: Sam Firke <sfirke@users.noreply.github.com>
2026-06-02 10:26:37 -07:00
Evan Rusackas
d26a7aac3d fix(dashboard): hide Edit button in embedded dashboards (#40687)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 10:17:10 -07:00
jesperct
699e741c69 fix(time-comparison): shift offset filter when X-axis is adhoc Custom SQL (#40586) 2026-06-02 09:52:42 -07:00
Jean Massucatto
fc0245bdb0 fix(charts): show non-filterable columns in metric section for table … (#39524) 2026-06-02 18:31:42 +02:00
Jean Massucatto
7275116f4c fix(world-map): preserve bubbles and exclude only null metrics from color scale (#39926) 2026-06-02 18:05:49 +02:00
Richard Fogaca Nienkotter
88abd41c8b fix(sql-lab): prevent crash when host shell lacks useAppDispatch export (#40591)
Co-authored-by: yousoph <sophieyou12@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:52:01 -03:00
Jean Massucatto
ddb647cd3a fix(dashboard): clear undo history (#40569) 2026-06-02 17:47:27 +02:00
Evan Rusackas
aba6ea536c fix(dashboard): prevent "undefined undefined" owner names in properties modal (#40528)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 08:31:37 -07:00
Evan Rusackas
ca8855dc03 fix(mcp): generic auth errors, required token expiry, and safer auth logging (#40646)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 08:31:05 -07:00
Evan Rusackas
052e567f77 fix: guard dynamic dispatch and bound a regex quantifier (#40547)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-02 08:09:22 -07:00
Mehmet Salih Yavuz
e2ed989639 fix(reports): skip permalink when dashboard state has no anchor or filters (#40530) 2026-06-02 11:37:30 +03:00
Kamil Gabryjelski
2abbb64e6b feat(gsheets): restore public/private sheet selector (#40466)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:10:21 +02:00
Evan Rusackas
c6faa50338 fix(cli): encrypt sqlalchemy_uri password on import_datasources (#31983) (#40584)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:08:48 -07:00
Evan Rusackas
817a35f445 fix(mcp): deny deactivated user accounts in MCP authentication (#40631)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-01 22:08:29 -07:00
Amin Ghadersohi
a6d2c95480 chore(deps): migrate MCP service JWT errors from authlib.jose to joserfc (#40582) 2026-06-02 00:41:17 -04:00
dependabot[bot]
c29591b3b1 chore(deps): update @babel/runtime requirement from ^7.29.2 to ^7.29.7 in /superset-frontend/packages/superset-ui-core (#40606)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:11:00 -07:00
dependabot[bot]
365914f1c7 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.59.4 to 8.60.0 in /superset-websocket (#40595)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 20:10:34 -07:00
dependabot[bot]
41da35e9db chore(deps-dev): bump typescript-eslint from 8.59.4 to 8.60.0 in /docs (#40598)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 17:03:52 -07:00
dependabot[bot]
861e668f74 chore(deps-dev): bump @babel/preset-env from 7.29.5 to 7.29.7 in /superset-frontend (#40601)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 17:03:37 -07:00
dependabot[bot]
41059c68bb chore(deps-dev): bump @babel/plugin-transform-modules-commonjs from 7.28.6 to 7.29.7 in /superset-frontend (#40603)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 17:03:30 -07:00
dependabot[bot]
94092d2f72 chore(deps-dev): bump @babel/preset-typescript from 7.28.5 to 7.29.7 in /superset-frontend (#40604)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 17:03:22 -07:00
dependabot[bot]
986148d924 chore(deps-dev): bump typescript-eslint from 8.59.4 to 8.60.0 in /superset-websocket (#40596)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:42:12 -07:00
dependabot[bot]
f04221a06c chore(deps-dev): bump webpack from 5.107.1 to 5.107.2 in /docs (#40597)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:42:05 -07:00
dependabot[bot]
70aa96458a chore(deps-dev): bump @babel/cli from 7.28.6 to 7.29.7 in /superset-frontend (#40599)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:41:51 -07:00
dependabot[bot]
8beea84952 chore(deps-dev): bump @typescript-eslint/parser from 8.59.4 to 8.60.0 in /docs (#40600)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:41:16 -07:00
dependabot[bot]
3f0fbbaac9 chore(deps-dev): update @babel/types requirement from ^7.29.0 to ^7.29.7 in /superset-frontend/plugins/plugin-chart-pivot-table (#40605)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:40:36 -07:00
dependabot[bot]
ce602fc5a8 chore(deps-dev): bump @babel/types from 7.29.0 to 7.29.7 in /superset-frontend (#40608)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:40:16 -07:00
dependabot[bot]
8731974e5c chore(deps-dev): bump @babel/preset-react from 7.28.5 to 7.29.7 in /superset-frontend (#40609)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:40:10 -07:00
dependabot[bot]
a06eb8fc78 chore(deps): bump react-arborist from 3.7.0 to 3.8.0 in /superset-frontend (#40610)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:39:55 -07:00
dependabot[bot]
aa8b474c58 chore(deps-dev): bump @babel/plugin-transform-export-namespace-from from 7.27.1 to 7.29.7 in /superset-frontend (#40611)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:39:48 -07:00
dependabot[bot]
efdfefeea2 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.59.4 to 8.60.0 in /superset-frontend (#40613)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:39:38 -07:00
dependabot[bot]
f77fa3ae39 chore(deps-dev): bump @babel/plugin-transform-runtime from 7.29.0 to 7.29.7 in /superset-frontend (#40614)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:39:29 -07:00
dependabot[bot]
bffc3fc58f chore(deps-dev): bump @babel/eslint-parser from 7.28.6 to 7.29.7 in /superset-frontend (#40615)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 16:39:23 -07:00
Evan Rusackas
2b8e31bf68 ci(docs): skip Netlify docs preview on PRs that don't touch docs (#40590)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-01 19:06:57 -04:00
Ville Brofeldt
74d1c83ec5 ci: preserve PR translation updates during regression checks (#40581) 2026-06-01 15:54:20 -07:00
dependabot[bot]
1523d797ca chore(deps-dev): bump pyinstrument from 4.4.0 to 5.1.2 (#40377)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Evan <evan@preset.io>
2026-06-01 13:34:12 -07:00
266 changed files with 10531 additions and 1911 deletions

View File

@@ -20,10 +20,6 @@ set -e
GITHUB_WORKSPACE=${GITHUB_WORKSPACE:-.}
ASSETS_MANIFEST="$GITHUB_WORKSPACE/superset/static/assets/manifest.json"
# Rounded job start time, used to create a unique Cypress build id for
# parallelization so we can manually rerun a job after 20 minutes
NONCE=$(echo "$(date "+%Y%m%d%H%M") - ($(date +%M)%20)" | bc)
# Echo only when not in parallel mode
say() {
if [[ $(echo "$INPUT_PARALLEL" | tr '[:lower:]' '[:upper:]') != 'TRUE' ]]; then

View File

@@ -38,6 +38,19 @@ jobs:
if: steps.check.outputs.python
uses: ./.github/actions/setup-backend/
# Authenticate the Docker daemon so the python:slim pull in
# uv-pip-compile.sh uses our (much higher) authenticated rate limit
# instead of the shared-runner anonymous one. Best-effort: on fork PRs the
# secrets are unavailable, so this no-ops and the pull falls back to
# anonymous (covered by the retry loop in the script).
- name: Login to Docker Hub
if: steps.check.outputs.python
continue-on-error: true
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run uv
if: steps.check.outputs.python
run: ./scripts/uv-pip-compile.sh

View File

@@ -12,6 +12,11 @@ on:
permissions:
contents: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
validate-all-ghas:

View File

@@ -2,6 +2,11 @@ name: "Pull Request Labeler"
on:
- pull_request_target
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
labeler:
permissions:

View File

@@ -8,6 +8,11 @@ on:
# Possible values: https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request
types: [opened, edited, reopened, synchronize]
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
lint-check:
runs-on: ubuntu-24.04

View File

@@ -1,12 +1,16 @@
name: E2E
on:
push:
branches:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
# Gated behind pre-commit: this workflow runs only after the "pre-commit
# checks" workflow completes, and (via the job-level `if` below) only when
# it succeeded. That keeps the expensive Cypress/Playwright runners from
# spinning up while a PR still has formatting/lint/type errors that
# pre-commit catches in a fraction of the time. pre-commit itself runs on
# push (master/release) and pull_request, so this preserves coverage for
# both event types.
workflow_run:
workflows: ["pre-commit checks"]
types: [completed]
workflow_dispatch:
inputs:
use_dashboard:
@@ -23,11 +27,46 @@ on:
default: ''
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
# workflow_run has no PR number in context; key on the originating branch.
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: true
jobs:
changes:
# The pre-commit gate: only proceed when pre-commit succeeded (or on a
# manual dispatch). On failure this job is skipped, and every downstream
# job (needs: changes) is skipped with it — no runners are provisioned.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
# The shared change-detector action reads the live event context, which
# under workflow_run points at the default branch. Call the script
# directly instead, passing the originating event/SHA/PR via WF_RUN_*.
- name: Check for file changes
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_RUN_EVENT: ${{ github.event.workflow_run.event }}
WF_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
WF_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
run: python scripts/change_detector.py
cypress-matrix:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
# Somehow one test flakes on 24.04 for unknown reasons, this is the only GHA left on 22.04
runs-on: ubuntu-22.04
permissions:
@@ -40,9 +79,14 @@ jobs:
# https://github.com/cypress-io/github-action/issues/48
fail-fast: false
matrix:
parallel_id: [0, 1, 2, 3, 4, 5]
parallel_id: [0, 1]
browser: ["chrome"]
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
app_root: ${{ github.event.workflow_run.event == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
# The /app/prefix variant (push events only) is smoke-tested on a single
# shard rather than the full matrix, so exclude it from the other shards.
exclude:
- parallel_id: 1
app_root: "/app/prefix"
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -67,13 +111,13 @@ jobs:
steps:
# -------------------------------------------------------
# Conditional checkout based on context
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
- name: Checkout (gated by pre-commit via workflow_run)
if: github.event_name == 'workflow_run'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -89,51 +133,38 @@ jobs:
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python || steps.check.outputs.frontend
- name: Setup postgres
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Install cypress
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: cypress-install
- name: Run Cypress
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
CYPRESS_BROWSER: ${{ matrix.browser }}
PARALLEL_ID: ${{ matrix.parallel_id }}
PARALLELISM: 6
PARALLELISM: 2
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
NODE_OPTIONS: "--max-old-space-size=4096"
with:
@@ -154,6 +185,8 @@ jobs:
name: cypress-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}-${{ matrix.parallel_id }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
playwright-tests:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
permissions:
contents: read
@@ -162,7 +195,7 @@ jobs:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
app_root: ${{ github.event.workflow_run.event == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -185,13 +218,13 @@ jobs:
steps:
# -------------------------------------------------------
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
- name: Checkout (gated by pre-commit via workflow_run)
if: github.event_name == 'workflow_run'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -207,51 +240,37 @@ jobs:
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python || steps.check.outputs.frontend
- name: Setup postgres
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Build embedded SDK
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-embedded-sdk
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright (Required Tests)
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
@@ -273,3 +292,34 @@ jobs:
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
# workflow_run runs don't attach their checks to the originating PR, so post
# an aggregate commit status back onto the PR head SHA. Make THIS the required
# status check in branch protection (in place of the individual E2E jobs).
report-status:
needs: [cypress-matrix, playwright-tests]
if: always() && github.event_name == 'workflow_run'
runs-on: ubuntu-24.04
permissions:
statuses: write
steps:
- name: Report aggregate E2E status to PR commit
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// 'skipped' is acceptable: the change-detector legitimately skips
// jobs when no relevant files changed. Only real failures fail.
const results = [
'${{ needs.cypress-matrix.result }}',
'${{ needs.playwright-tests.result }}',
];
const ok = results.every((r) => r === 'success' || r === 'skipped');
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.payload.workflow_run.head_sha,
state: ok ? 'success' : 'failure',
context: 'E2E / required',
description: ok ? 'E2E passed (or skipped)' : 'E2E failed',
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
});

View File

@@ -23,9 +23,30 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests)
# This workflow contains only experimental tests that run in shadow mode
playwright-tests-experimental:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
continue-on-error: true
permissions:
@@ -80,58 +101,43 @@ jobs:
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python || steps.check.outputs.frontend
- name: Setup postgres
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Build embedded SDK
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-embedded-sdk
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright (Experimental Tests)
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run "${{ matrix.app_root }}" experimental/
- name: Run Playwright (Embedded Tests)
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"

View File

@@ -14,7 +14,27 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
test-mysql:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -27,6 +47,8 @@ jobs:
services:
mysql:
image: mysql:8.0
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
env:
MYSQL_ROOT_PASSWORD: root
ports:
@@ -47,26 +69,17 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python
- name: Setup MySQL
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: setup-mysql
- name: Start Celery worker
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: celery-worker
- name: Python integration tests (MySQL)
if: steps.check.outputs.python
run: |
./scripts/python_tests.sh
- name: Upload code coverage
@@ -77,7 +90,6 @@ jobs:
use_oidc: true
slug: apache/superset
- name: Generate database diagnostics for docs
if: steps.check.outputs.python
env:
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
SUPERSET__SQLALCHEMY_DATABASE_URI: |
@@ -100,13 +112,14 @@ jobs:
print(f'Generated diagnostics for {len(docs)} databases')
"
- name: Upload database diagnostics artifact
if: steps.check.outputs.python
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: database-diagnostics
path: databases-diagnostics.json
retention-days: 7
test-postgres:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -138,29 +151,20 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python
with:
python-version: ${{ matrix.python-version }}
- name: Setup Postgres
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: |
setup-postgres
- name: Start Celery worker
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: celery-worker
- name: Python integration tests (PostgreSQL)
if: steps.check.outputs.python
run: |
./scripts/python_tests.sh
- name: Upload code coverage
@@ -172,6 +176,8 @@ jobs:
slug: apache/superset
test-sqlite:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -194,28 +200,19 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python
- name: Install dependencies
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: |
# sqlite needs this working directory
mkdir ${{ github.workspace }}/.temp
- name: Start Celery worker
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: celery-worker
- name: Python integration tests (SQLite)
if: steps.check.outputs.python
run: |
./scripts/python_tests.sh
- name: Upload code coverage

View File

@@ -15,7 +15,27 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
test-postgres-presto:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -54,28 +74,17 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python == 'true'
- name: Setup Postgres
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: |
echo "${{ steps.check.outputs.python }}"
setup-postgres
run: setup-postgres
- name: Start Celery worker
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: celery-worker
- name: Python unit tests (PostgreSQL)
if: steps.check.outputs.python
run: |
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
@@ -87,6 +96,8 @@ jobs:
slug: apache/superset
test-postgres-hive:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -117,35 +128,23 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Create csv upload directory
if: steps.check.outputs.python
run: sudo mkdir -p /tmp/.superset/uploads
- name: Give write access to the csv upload directory
if: steps.check.outputs.python
run: sudo chown -R $USER:$USER /tmp/.superset
- name: Start hadoop and hive
if: steps.check.outputs.python
run: docker compose -f scripts/databases/hive/docker-compose.yml up -d
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python
- name: Setup Postgres
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Start Celery worker
if: steps.check.outputs.python
uses: ./.github/actions/cached-dependencies
with:
run: celery-worker
- name: Python unit tests (PostgreSQL)
if: steps.check.outputs.python
run: |
pip install -e .[hive]
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'

View File

@@ -15,7 +15,27 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
unit-tests:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -30,25 +50,17 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python
with:
python-version: ${{ matrix.python-version }}
- name: Python unit tests
if: steps.check.outputs.python
env:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50
- name: Python 100% coverage unit tests
if: steps.check.outputs.python
env:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret

View File

@@ -113,13 +113,9 @@ jobs:
--translations-dir /tmp/base-worktree/superset/translations \
> /tmp/before.json
# Reset the PR worktree's translations to the pristine BASE state so
# both babel_update runs start from the same .po files. The only
# difference between the runs is the source code.
- name: Reset PR worktree translations to pristine BASE
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: git checkout FETCH_HEAD -- superset/translations/
# Run babel_update against the PR source and PR translations. This keeps
# committed .po fixes in play while the base babel_update above still
# cancels out translation drift already present on the base branch.
- name: Run babel_update against PR source
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: ./scripts/translations/babel_update.sh

View File

@@ -109,7 +109,7 @@ If yes, it is in scope. If no, it is out of scope. The lists below apply that te
- Any action an Admin role can perform through documented configuration, API, or UI. The Admin role is a trusted operational principal by policy. Per MITRE CNA Operational Rules 4.1, a qualifying vulnerability must violate a security policy; behavior within a documented trust boundary does not.
- Deployment or operator decisions: the values of secrets and tokens, whether internal networks are reachable from the server, which database connectors or cache backends are enabled, which feature flags are set, where notifications are delivered, and which third-party plugins are loaded.
- Compromise, modification, or malicious control of trusted backend infrastructure. Apache Superset assumes the integrity of its metastore, cache backends (for example Redis or Memcached), message brokers, secret stores, and other operator-managed infrastructure. Findings that require an attacker to read from, write to, or otherwise tamper with these systems, including injecting malicious state, serialized objects, cache entries, task metadata, configuration, or database records, are post-compromise scenarios and do not constitute vulnerabilities in Apache Superset itself. A finding remains in scope only if an unprivileged user can cause such modification through a vulnerability in Apache Superset.
- Code paths whose intended purpose is example data, demos, fixtures, local development, or documentation, rather than the production runtime.
- The continued presence of expired key-value or metastore-cache entries that have not yet been deleted from the metadata database. Such entries are excluded from reads once expired, are purged opportunistically on write, and are removed in bulk by the scheduled `prune_key_value` maintenance task; their lingering until purged is an eventual-cleanup property, not a security boundary, and does not constitute a vulnerability.
- How a downstream application (spreadsheet program, email client, browser handling user-downloaded files) interprets output Apache Superset produced for it.
- Findings without a reproducible proof of concept against a supported release. The burden of demonstrating exploitability rests with the reporter; findings closed for lack of a proof of concept may be refiled if one is later produced.
- Brute force, rate limiting, denial of service, or resource exhaustion that does not bypass a documented control.

View File

@@ -24,6 +24,16 @@ assists people when migrating to a new version.
## Next
### YDB now uses a native sqlglot dialect
YDB SQL parsing now relies on the dedicated [`ydb-sqlglot-plugin`](https://pypi.org/project/ydb-sqlglot-plugin/) dialect, which registers itself with sqlglot automatically. YDB users must install this plugin (e.g., via `pip install "apache-superset[ydb]"`) to avoid a `ValueError` when Superset parses YDB queries.
### Embedded dashboards enforce configured Allowed Domains for postMessage
The embedded dashboard page now validates the origin of incoming `postMessage` events against the dashboard's configured **Allowed Domains**. The server-rendered embedded page exposes the configured domains in its bootstrap payload, and the frontend rejects message events whose origin is not in that list.
Enforcement only applies when the Allowed Domains list is non-empty. If the list is empty (the default), any origin is accepted, so there is no behavior change for embeds that did not configure Allowed Domains.
### 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.

View File

@@ -80,7 +80,23 @@ case "${1}" in
;;
app)
echo "Starting web app (using development server)..."
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
# Environment-based debugger control for security
# Only enable Werkzeug interactive debugger when explicitly requested
# Modern Werkzeug (3.0+) includes PIN protection, but defense-in-depth approach
# Override FLASK_DEBUG so the effective state matches SUPERSET_DEBUG_ENABLED even
# when FLASK_DEBUG=true is inherited from docker/.env or .flaskenv
if [[ "${SUPERSET_DEBUG_ENABLED:-}" == "true" ]]; then
export FLASK_DEBUG=1
DEBUGGER_FLAG="--debugger"
echo " ⚠️ Werkzeug debugger enabled (requires PIN for /console access)"
else
export FLASK_DEBUG=0
DEBUGGER_FLAG="--no-debugger"
echo " 🔒 Werkzeug debugger disabled (set SUPERSET_DEBUG_ENABLED=true to enable)"
fi
flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -157,8 +157,15 @@ superset load_examples
superset init
# To start a development web server on port 8088, use -p to bind to another port
superset run -p 8088 --with-threads --reload --debugger
superset run -p 8088 --with-threads --reload
# For debugging with interactive console (⚠️ localhost only)
# superset run -p 8088 --with-threads --reload --debugger
```
:::warning Security Note
The `--debugger` flag enables Werkzeug's interactive console at `/console`. Only use this for local development and never bind to `0.0.0.0` or expose the server to networks when debugging is enabled.
:::
If everything worked, you should be able to navigate to `hostname:port` in your browser (e.g.
locally by default at `localhost:8088`) and login using the username and password you created.

View File

@@ -157,8 +157,15 @@ superset load_examples
superset init
# To start a development web server on port 8088, use -p to bind to another port
superset run -p 8088 --with-threads --reload --debugger
superset run -p 8088 --with-threads --reload
# For debugging with interactive console (⚠️ localhost only)
# superset run -p 8088 --with-threads --reload --debugger
```
:::warning Security Note
The `--debugger` flag enables Werkzeug's interactive console at `/console`. Only use this for local development and never bind to `0.0.0.0` or expose the server to networks when debugging is enabled.
:::
If everything worked, you should be able to navigate to `hostname:port` in your browser (e.g.
locally by default at `localhost:8088`) and login using the username and password you created.

View File

@@ -102,6 +102,8 @@ Affecting the Docker build process:
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`
- **SUPERSET_LOG_LEVEL (default=info)**: Can be set to debug, info, warning, error, critical
for more verbose logging
- **SUPERSET_DEBUG_ENABLED (default=false)**: Enable Werkzeug debugger with interactive console.
Set to `true` for debugging: `SUPERSET_DEBUG_ENABLED=true docker compose up`
For more env vars that affect your configuration, see this
[superset_config.py](https://github.com/apache/superset/blob/master/docker/pythonpath_dev/superset_config.py)

View File

@@ -25,9 +25,17 @@
command = "yarn install && yarn build"
# Output directory (relative to base)
publish = "build"
# Skip builds when no docs changes (exit 0 = skip, exit 1 = build)
# Checks for changes in docs/ and README.md (which gets pulled into docs)
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../README.md"
# Skip builds when no docs changes (exit 0 = skip, non-zero = build).
# Checks for changes in docs/ and README.md (which gets pulled into docs).
#
# $CACHED_COMMIT_REF is the last *deployed* commit. On a PR's first build it
# is empty, so the original `git diff` errored and Netlify fell back to
# building -- which is why every PR built a docs preview once even with no
# docs changes. When it is empty we instead diff the whole branch against its
# merge-base with master, so non-docs PRs are skipped from the very first
# build. Subsequent builds (and the master production build) keep the cheaper
# incremental $CACHED_COMMIT_REF diff. Any failure exits non-zero -> build.
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" "$COMMIT_REF" -- . ../README.md; else git fetch origin master --depth=100 >/dev/null 2>&1; git diff --quiet "$(git merge-base origin/master "$COMMIT_REF" 2>/dev/null || echo origin/master)" "$COMMIT_REF" -- . ../README.md; fi'
[build.environment]
# Node version matching docs/.nvmrc

View File

@@ -101,7 +101,7 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/parser": "^8.60.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
@@ -109,8 +109,8 @@
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.4",
"webpack": "^5.107.1"
"typescript-eslint": "^8.60.0",
"webpack": "^5.107.2"
},
"browserslist": {
"production": [

View File

@@ -4812,100 +4812,110 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
"@typescript-eslint/eslint-plugin@8.60.0", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz#8fc1e0a950c43270eaf0212dc060f7edaa42f9cf"
integrity sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.59.4"
"@typescript-eslint/type-utils" "8.59.4"
"@typescript-eslint/utils" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
"@typescript-eslint/scope-manager" "8.60.0"
"@typescript-eslint/type-utils" "8.60.0"
"@typescript-eslint/utils" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
"@typescript-eslint/parser@8.60.0", "@typescript-eslint/parser@^8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.0.tgz#38d611b8e658cb10850d4975e8a175a222fbcd6a"
integrity sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==
dependencies:
"@typescript-eslint/scope-manager" "8.59.4"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
"@typescript-eslint/scope-manager" "8.60.0"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
"@typescript-eslint/project-service@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.0.tgz#b82ab12e64d005d0c7163d1240c432381f1bde0f"
integrity sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.59.4"
"@typescript-eslint/types" "^8.59.4"
"@typescript-eslint/tsconfig-utils" "^8.60.0"
"@typescript-eslint/types" "^8.60.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
"@typescript-eslint/scope-manager@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz#7617a4617c043fe235dcf066f9a40f106cfd2fd5"
integrity sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==
dependencies:
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.0"
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
"@typescript-eslint/tsconfig-utils@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz#3af78c48956227a407dea9626b8db8ca53f130d2"
integrity sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==
"@typescript-eslint/type-utils@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
"@typescript-eslint/tsconfig-utils@^8.60.0":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
"@typescript-eslint/type-utils@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz#6971a61bc4f3a1b2df45dcc14e26a43a88a4cb6a"
integrity sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==
dependencies:
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/utils" "8.59.4"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/utils" "8.60.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
"@typescript-eslint/types@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.0.tgz#e77ad768e933263b1960b2fe79de75fe1cc6e7db"
integrity sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==
"@typescript-eslint/typescript-estree@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
"@typescript-eslint/types@^8.60.0":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
"@typescript-eslint/typescript-estree@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz#c102196a44414481190041c99eea1d854e66001b"
integrity sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==
dependencies:
"@typescript-eslint/project-service" "8.59.4"
"@typescript-eslint/tsconfig-utils" "8.59.4"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
"@typescript-eslint/project-service" "8.60.0"
"@typescript-eslint/tsconfig-utils" "8.60.0"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.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.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
"@typescript-eslint/utils@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.0.tgz#6110cddaef87606ae4ca6f8bf81bb5949fc8e098"
integrity sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.59.4"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/scope-manager" "8.60.0"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/visitor-keys@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
"@typescript-eslint/visitor-keys@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz#f2c41eedd3d7b03b808369fb2e3fb40a93783ec2"
integrity sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==
dependencies:
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/types" "8.60.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -7248,10 +7258,10 @@ encodeurl@~2.0.0:
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
enhanced-resolve@^5.21.4:
version "5.21.5"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
enhanced-resolve@^5.22.0:
version "5.22.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz#c34bc3f414298496fc244b21bbe316440782da17"
integrity sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.3.3"
@@ -14372,15 +14382,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.59.4:
version "8.59.4"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
typescript-eslint@^8.60.0:
version "8.60.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.0.tgz#6686fecb1f4f367c0bf0075828e93b7ecacbc62b"
integrity sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==
dependencies:
"@typescript-eslint/eslint-plugin" "8.59.4"
"@typescript-eslint/parser" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/utils" "8.59.4"
"@typescript-eslint/eslint-plugin" "8.60.0"
"@typescript-eslint/parser" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/utils" "8.60.0"
typescript@~6.0.3:
version "6.0.3"
@@ -14930,20 +14940,20 @@ webpack-merge@^6.0.1:
flat "^5.0.2"
wildcard "^2.0.1"
webpack-sources@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
webpack-sources@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.0.tgz#87bf7f5801a4e985b1f1c92b64b9620a02f76d08"
integrity sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==
webpack-virtual-modules@^0.6.2:
version "0.6.2"
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
version "5.107.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
webpack@^5.107.2, webpack@^5.88.1, webpack@^5.95.0:
version "5.107.2"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.2.tgz#dea14dcb177b46b29de15f952f7303691ee2b596"
integrity sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==
dependencies:
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"
@@ -14954,7 +14964,7 @@ webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
acorn-import-phases "^1.0.3"
browserslist "^4.28.1"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.21.4"
enhanced-resolve "^5.22.0"
es-module-lexer "^2.1.0"
eslint-scope "5.1.1"
events "^3.2.0"
@@ -14967,7 +14977,7 @@ webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
tapable "^2.3.0"
terser-webpack-plugin "^5.5.0"
watchpack "^2.5.1"
webpack-sources "^3.4.1"
webpack-sources "^3.5.0"
webpackbar@^7.0.0:
version "7.0.0"

View File

@@ -154,7 +154,7 @@ fastmcp = [
]
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
hive = [
@@ -208,7 +208,7 @@ netezza = ["nzalchemy>=11.0.2"]
starrocks = ["starrocks>=1.0.0"]
doris = ["pydoris>=1.0.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1"]
ydb = ["ydb-sqlalchemy>=0.1.2"]
ydb = ["ydb-sqlalchemy>=0.1.2", "ydb-sqlglot-plugin>=0.2.5"]
development = [
# no bounds for apache-superset-extensions-cli until a stable version
"apache-superset-extensions-cli",
@@ -225,7 +225,7 @@ development = [
"progress>=1.5,<2",
"psutil",
"pyfakefs",
"pyinstrument>=4.0.2,<6",
"pyinstrument>=5.1.2,<6",
"pylint",
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
"pytest-asyncio",
@@ -456,6 +456,7 @@ authorized_licenses = [
"isc license (iscl)",
"isc license",
"mit",
"mit and psf-2.0",
"mit-cmu",
"mozilla public license 2.0 (mpl 2.0)",
"osi approved",

View File

@@ -161,7 +161,7 @@ geopy==2.4.1
# via apache-superset (pyproject.toml)
google-auth==2.43.0
# via shillelagh
greenlet==3.1.1
greenlet==3.5.0
# via
# apache-superset (pyproject.toml)
# shillelagh

View File

@@ -331,7 +331,7 @@ geopy==2.4.1
# via
# -c requirements/base-constraint.txt
# apache-superset
gevent==24.2.1
gevent==26.4.0
# via apache-superset
google-api-core==2.23.0
# via
@@ -373,7 +373,7 @@ googleapis-common-protos==1.66.0
# via
# google-api-core
# grpcio-status
greenlet==3.1.1
greenlet==3.5.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -768,7 +768,7 @@ pygments==2.20.0
# rich
pyhive==0.7.0
# via apache-superset
pyinstrument==4.4.0
pyinstrument==5.1.2
# via apache-superset
pyjwt==2.12.0
# via

16
scripts/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -109,6 +109,37 @@ def is_int(s: str) -> bool:
return bool(re.match(r"^-?\d+$", s))
def resolve_workflow_run_files(repo: str, sha: str) -> Optional[List[str]]:
"""Resolve changed files for a workflow_run-triggered run.
When a workflow is gated behind another (e.g. running only after
pre-commit succeeds), GitHub re-dispatches it as a `workflow_run` event
whose context points at the default branch rather than the originating
diff. Recover the original event and head SHA from the workflow_run
payload, exposed via the WF_RUN_* env vars. Returns ``None`` (meaning
"assume all changed") when the diff can't be resolved.
"""
original_event = os.getenv("WF_RUN_EVENT") or "push"
print("ORIGINAL_EVENT", original_event)
if original_event == "pull_request":
pr_number = os.getenv("WF_RUN_PR_NUMBER", "")
if not is_int(pr_number):
# Fork PRs don't populate workflow_run.pull_requests, so we can't
# resolve the diff -> assume all changed (run everything).
print("workflow_run without PR context, assuming all changed")
return None
files = fetch_changed_files_pr(repo, pr_number)
print("PR files:")
print_files(files)
return files
head_sha = os.getenv("WF_RUN_HEAD_SHA") or sha
files = fetch_changed_files_push(repo, head_sha)
print("Files touched since previous commit:")
print_files(files)
return files
def main(event_type: str, sha: str, repo: str) -> None:
"""Main function to check for file changes based on event context."""
print("SHA:", sha)
@@ -126,6 +157,9 @@ def main(event_type: str, sha: str, repo: str) -> None:
print("Files touched since previous commit:")
print_files(files)
elif event_type == "workflow_run":
files = resolve_workflow_run_files(repo, sha)
elif event_type in ("workflow_dispatch", "schedule"):
# Manual or cron-triggered runs aren't tied to a specific diff, so
# treat every group as changed. `files = None` makes the loop below

View File

@@ -18,14 +18,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* —
i.e. a string was renamed/reworded so its committed translation no longer
applies. ``babel_update.sh`` (``pybabel update --ignore-obsolete``) surfaces
exactly these as **newly fuzzy** entries: the old translation is fuzzy-matched
onto the new ``msgid`` and flagged ``#, fuzzy``.
Crucially, *deleting* a translatable string is **not** a regression. With
``--ignore-obsolete`` a removed string is dropped from the catalogs entirely;
no fuzzy entry is created. So a PR that intentionally removes a string (e.g. a
security fix that stops rendering a value) legitimately lowers the translated
count without introducing any fuzzies, and must not be flagged. We therefore
key the check on the **increase in fuzzy entries**, not on a drop in the
translated count (a drop happens identically for a benign deletion and a real
rename, so it cannot distinguish the two).
Usage
-----
Count non-fuzzy translated entries in all .po files and write JSON to stdout:
Count translated + fuzzy entries in all .po files and write JSON to stdout:
python check_translation_regression.py --count
Compare the current .po state against a previously-recorded baseline and fail
if any language lost translations:
if a source change invalidated existing translations (new fuzzies):
python check_translation_regression.py --compare /path/to/before.json
@@ -44,13 +61,14 @@ Typical CI workflow
1. Create a base-branch worktree alongside the PR worktree
2. Run babel_update.sh in the base worktree (extract from BASE source)
3. Record baseline: python ... --count --translations-dir BASE_TREE > before.json
4. Run babel_update.sh in the PR worktree (extract from PR source) starting
from the same pristine BASE translations
4. Run babel_update.sh in the PR worktree (extract from PR source and keep
any committed PR .po updates)
5. Compare: python ... --compare before.json [--report report.md]
Comparing two babel_update outputs that started from the same BASE .po files
isolates regressions caused by the PR's source diff from any pre-existing
drift on the base branch.
Running babel_update on the base branch first isolates regressions caused by
the PR's source diff from any pre-existing drift on the base branch, while the
PR worktree run still allows committed .po updates to resolve the fuzzies (and
thus clear the regression) before merging.
"""
import argparse
@@ -70,8 +88,13 @@ DEFAULT_TRANSLATIONS_DIR = (
SKIP_LANGS = {"en"}
def count_translated(po_file: Path) -> int:
"""Return the number of non-fuzzy translated messages in a .po file.
def count_stats(po_file: Path) -> dict[str, int]:
"""Return ``{"translated": int, "fuzzy": int}`` for a .po file.
``translated`` is the number of non-fuzzy translated messages; ``fuzzy`` is
the number of fuzzy translations. The fuzzy count is what the regression
check keys on — a source rename invalidates an existing translation by
making it fuzzy, whereas a deletion simply drops it (``--ignore-obsolete``).
Raises:
subprocess.CalledProcessError: if ``msgfmt`` fails (e.g. malformed
@@ -89,29 +112,50 @@ def count_translated(po_file: Path) -> int:
check=True,
)
# stderr: "123 translated messages, 4 fuzzy translations, 56 untranslated messages."
match = re.search(r"(\d+) translated message", result.stderr)
if not match:
# The fuzzy and untranslated clauses are omitted by msgfmt when they are 0.
translated_match = re.search(r"(\d+) translated message", result.stderr)
if not translated_match:
raise RuntimeError(
f"Could not parse msgfmt --statistics output for {po_file}: "
f"{result.stderr!r}"
)
return int(match.group(1))
fuzzy_match = re.search(r"(\d+) fuzzy translation", result.stderr)
return {
"translated": int(translated_match.group(1)),
"fuzzy": int(fuzzy_match.group(1)) if fuzzy_match else 0,
}
def get_counts(translations_dir: Path) -> dict[str, int]:
counts: dict[str, int] = {}
def get_counts(
translations_dir: Path,
failures: Optional[set[str]] = None,
) -> dict[str, dict[str, int]]:
"""Count translated/fuzzy entries for every ``.po`` file in a directory.
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.
"""
counts: dict[str, dict[str, int]] = {}
for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")):
lang = po_file.parent.parent.name
if lang in SKIP_LANGS:
continue
try:
counts[lang] = count_translated(po_file)
counts[lang] = count_stats(po_file)
except (subprocess.CalledProcessError, RuntimeError) as exc:
# A malformed .po file (msgfmt non-zero exit, or stderr we
# can't parse) is a real problem worth seeing, but it shouldn't
# take the whole regression check down with it — that would
# hide every other language's status. Skip and warn instead;
# the missing lang will not appear in the comparison output.
# hide every other language's status. Skip and warn here; the
# caller is told which langs failed via ``failures`` so it can
# decide whether a present-but-uncountable catalog is fatal.
if failures is not None:
failures.add(lang)
print(
f"WARNING: skipping {lang}{po_file} could not be counted: {exc}",
file=sys.stderr,
@@ -119,18 +163,42 @@ def get_counts(translations_dir: Path) -> dict[str, int]:
return counts
def _normalize(entry: object) -> dict[str, int]:
"""Coerce a baseline entry into ``{"translated", "fuzzy"}``.
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.
"""
if isinstance(entry, dict):
return {
"translated": int(entry.get("translated", 0)),
"fuzzy": int(entry.get("fuzzy", 0)),
}
if isinstance(entry, int):
return {"translated": entry, "fuzzy": 0}
raise TypeError(f"Unsupported baseline entry: {entry!r}")
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
"""Build a markdown report for posting as a PR comment."""
"""Build a markdown report for posting as a PR comment.
Each regression tuple is ``(lang, before_fuzzy, after_fuzzy)``.
"""
rows = "\n".join(
f"| `{lang}` | {b} | {a} | -{b - a} |" for lang, b, a in regressions
f"| `{lang}` | {b} | {a} | +{a - b} |" for lang, b, a in regressions
)
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
return (
"## ⚠️ Translation Regression Detected\n\n"
f"This PR causes existing translations to become fuzzy or be removed "
f"in {affected}. Please fix the affected `.po` files before merging.\n\n"
"| Language | Before | After | Lost |\n"
"|----------|-------:|------:|-----:|\n"
f"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"
f"{rows}\n\n"
"### How to fix\n\n"
"**1. Install dependencies** (if not already set up):\n\n"
@@ -168,26 +236,49 @@ def cmd_compare(
report_path: Optional[str] = None,
) -> None:
with open(before_path) as f:
before: dict[str, int] = json.load(f)
before_raw: dict[str, object] = json.load(f)
before = {lang: _normalize(entry) for lang, entry in before_raw.items()}
after = get_counts(translations_dir)
failures: set[str] = set()
after = get_counts(translations_dir, failures=failures)
# A baseline language whose catalog is *missing* from `after` is fine —
# that's an intentional catalog deletion (handled below like any other
# deletion). But a language whose .po file is still present yet could not
# be counted (msgfmt failed / output unparseable) is a hard error: leaving
# it out silently would let a corrupt catalog pass as "no regression".
broken = sorted(lang for lang in failures if lang in before)
if broken:
print("Translation check failed!\n")
for lang in broken:
print(f" {lang}: catalog present but could not be counted (msgfmt error)")
print(
"\nFix the malformed .po file(s) above before merging — a catalog "
"that cannot be parsed must not be silently dropped."
)
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_count in sorted(before.items()):
after_count = after.get(lang, 0)
if after_count < before_count:
regressions.append((lang, before_count, after_count))
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:
print("Translation regression detected!\n")
for lang, b, a in regressions:
lost = b - a
print(f" {lang}: {b} -> {a} (-{lost} string(s) became fuzzy or removed)")
print(
f" {lang}: {a - b} translation(s) invalidated "
f"(fuzzy {b} -> {a}) by a renamed/reworded source string"
)
print(
"\nStrings renamed or deleted by this PR invalidated existing translations."
)
print(
"Update the affected .po files to restore the lost entries before merging."
"\nResolve the newly-fuzzy entries in the affected .po files "
"before merging."
)
if report_path:
Path(report_path).write_text(
@@ -198,15 +289,15 @@ 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):
b = before.get(lang, 0)
a = after[lang]
if a > b:
delta = f"+{a - b}"
elif a == b:
delta = "no change"
else:
delta = f"-{b - a}"
print(f" {lang}: {b} -> {a} ({delta})")
before_stats = before.get(lang, {"translated": 0, "fuzzy": 0})
after_stats = after[lang]
t_delta = after_stats["translated"] - before_stats["translated"]
f_delta = after_stats["fuzzy"] - before_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})"
)
def main() -> None:

View File

@@ -31,11 +31,32 @@ if [ -z "$RUNNING_IN_DOCKER" ]; then
echo "Running in Docker (Python ${PYTHON_VERSION} on Linux)..."
IMAGE="python:${PYTHON_VERSION}-slim"
# Pre-pull the image with a few retries to absorb transient Docker Hub
# registry failures ("context deadline exceeded" / anonymous rate-limit blips
# on shared CI runners). Without this a flaky pull fails the whole
# check-python-deps job on an infrastructure hiccup rather than a real
# dependency drift. The pull is in the `until` condition so `set -e` does not
# abort on an individual failed attempt.
attempt=1
max_attempts=4
until docker pull "$IMAGE"; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "docker pull $IMAGE failed after ${max_attempts} attempts" >&2
exit 1
fi
delay=$((attempt * 10))
echo "docker pull $IMAGE failed (attempt ${attempt}/${max_attempts}); retrying in ${delay}s..." >&2
sleep "$delay"
attempt=$((attempt + 1))
done
docker run --rm \
-v "$(pwd)":/app \
-w /app \
-e RUNNING_IN_DOCKER=1 \
python:${PYTHON_VERSION}-slim \
"$IMAGE" \
bash -c "pip install uv && ./scripts/uv-pip-compile.sh $*"
exit $?

View File

@@ -80,7 +80,7 @@ const restrictedImportsRules = {
'no-jest-mock-console': {
name: 'jest-mock-console',
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
}
},
};
module.exports = {

File diff suppressed because it is too large Load Diff

View File

@@ -98,6 +98,7 @@
],
"dependencies": {
"@apache-superset/core": "file:packages/superset-core",
"@braintree/sanitize-url": "^7.1.2",
"@deck.gl/aggregation-layers": "~9.2.5",
"@deck.gl/core": "~9.2.5",
"@deck.gl/extensions": "~9.2.5",
@@ -204,7 +205,7 @@
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react-arborist": "^3.7.0",
"react-arborist": "^3.8.0",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
@@ -243,22 +244,22 @@
"yargs": "^18.0.0"
},
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/cli": "^7.29.7",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.29.0",
"@babel/eslint-parser": "^7.28.6",
"@babel/eslint-parser": "^7.29.7",
"@babel/node": "^7.29.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
"@babel/plugin-transform-runtime": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@babel/register": "^7.29.3",
"@babel/runtime": "^7.29.2",
"@babel/runtime-corejs3": "^7.29.2",
"@babel/types": "^7.28.6",
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
@@ -305,7 +306,7 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.59.4",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",

View File

@@ -73,11 +73,11 @@
"author": "Apache Software Foundation",
"license": "Apache-2.0",
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"typescript": "^5.0.0",
"@emotion/styled": "^11.14.1",
"@types/lodash": "^4.17.24",

View File

@@ -115,6 +115,21 @@ export const GlobalStyles = () => {
display: flex;
margin-top: ${theme.marginXS}px;
}
.superset-explore-popover.ant-popover
.ant-popover-inner:has(.ant-popover-title) {
padding-top: 0;
}
.superset-explore-popover.ant-popover .ant-popover-title {
padding-top: ${theme.paddingXS}px;
margin-bottom: ${theme.paddingSM}px;
line-height: 1;
}
.superset-explore-popover.ant-popover
.ant-popover-inner:has(.ant-popover-title)
.ant-tabs-tab {
padding-top: 0;
}
`}
/>
);

View File

@@ -25,7 +25,7 @@ import {
} from '@superset-ui/core';
import { PostProcessingFactory } from './types';
const PERCENTILE_REGEX = /(\d+)\/(\d+) percentiles/;
const PERCENTILE_REGEX = /(\d{1,3})\/(\d{1,3}) percentiles/;
export const boxplotOperator: PostProcessingFactory<PostProcessingBoxplot> = (
formData,

View File

@@ -26,7 +26,8 @@
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.2",
"@babel/runtime": "^7.29.7",
"@braintree/sanitize-url": "^7.1.2",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.44.0",

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import { FC } from 'react';
import { styled, useTheme, css } from '@apache-superset/core/theme';
import { Skeleton } from '../Skeleton';
@@ -140,7 +141,7 @@ const ThinSkeleton = styled(Skeleton)`
const paragraphConfig = { rows: 1, width: 150 };
const AnchorLink: FC<LinkProps> = ({ to, children }) => (
<a href={to}>{children}</a>
<a href={to !== undefined ? sanitizeUrl(to) : undefined}>{children}</a>
);
function ListViewCard({

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import callApiAndParseWithTimeout from './callApi/callApiAndParseWithTimeout';
import {
ClientConfig,
@@ -123,7 +124,7 @@ export default class SupersetClientClass {
if (endpoint) {
await this.ensureAuth();
const hiddenForm = document.createElement('form');
hiddenForm.action = this.getUrl({ endpoint });
hiddenForm.action = sanitizeUrl(this.getUrl({ endpoint }));
hiddenForm.method = 'POST';
hiddenForm.target = target;
const payloadWithToken: Record<string, any> = {

View File

@@ -33,6 +33,35 @@ describe('sanitizeHtml', () => {
const sanitizedString = sanitizeHtml(htmlString);
expect(sanitizedString).not.toContain('script');
});
test('should preserve allowed presentational CSS properties', () => {
const htmlString =
'<div style="color: red; background-color: blue; font-size: 12px; text-align: center">x</div>';
const sanitizedString = sanitizeHtml(htmlString);
expect(sanitizedString).toContain('color:red');
expect(sanitizedString).toContain('background-color:blue');
expect(sanitizedString).toContain('font-size:12px');
expect(sanitizedString).toContain('text-align:center');
});
test('should strip layout and positioning CSS properties', () => {
const htmlString =
'<div style="color: red; position: fixed; z-index: 9999; width: 100%; height: 100%">x</div>';
const sanitizedString = sanitizeHtml(htmlString);
expect(sanitizedString).toContain('color:red');
expect(sanitizedString).not.toContain('position');
expect(sanitizedString).not.toContain('z-index');
expect(sanitizedString).not.toContain('width');
expect(sanitizedString).not.toContain('height');
});
test('should strip unsafe CSS property values', () => {
const htmlString =
'<div style="background-color: url(javascript:alert(1)); color: blue">x</div>';
const sanitizedString = sanitizeHtml(htmlString);
expect(sanitizedString).not.toContain('javascript');
expect(sanitizedString).not.toContain('url(');
});
});
describe('isProbablyHTML', () => {

View File

@@ -19,6 +19,50 @@
import { FilterXSS, getDefaultWhiteList } from 'xss';
import { DataRecordValue } from '../types';
// Restrict inline `style` attributes to a small set of presentational CSS
// properties. Overlay/positioning properties (e.g. position, z-index, top,
// left, transform) and sizing properties that could cover the page (e.g.
// width, height) are intentionally excluded so that sanitized markup cannot
// escape its container to overlay or obscure the surrounding page. The
// allowlisted spacing/border properties (margin, padding, border) can still
// affect layout within the container, which is acceptable. The `xss` library
// also validates property values against this allowlist, stripping unsupported
// constructs such as url()/expression().
const allowedCssProperties = {
color: true,
'background-color': true,
'text-align': true,
'text-decoration': true,
'font-family': true,
'font-size': true,
'font-style': true,
'font-weight': true,
'line-height': true,
'letter-spacing': true,
'white-space': true,
padding: true,
'padding-top': true,
'padding-right': true,
'padding-bottom': true,
'padding-left': true,
margin: true,
'margin-top': true,
'margin-right': true,
'margin-bottom': true,
'margin-left': true,
border: true,
'border-color': true,
'border-style': true,
'border-width': true,
'border-radius': true,
'vertical-align': true,
// Needed by ECharts tooltips for row transparency and text truncation.
opacity: true,
'max-width': true,
overflow: true,
'text-overflow': true,
};
const xssFilter = new FilterXSS({
whiteList: {
...getDefaultWhiteList(),
@@ -45,7 +89,7 @@ const xssFilter = new FilterXSS({
tfoot: ['align', 'valign', 'style'],
},
stripIgnoreTag: true,
css: false,
css: { whiteList: allowedCssProperties },
});
export function sanitizeHtml(htmlString: string) {

View File

@@ -161,10 +161,13 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
</table>
`;
// The `xss` CSS filter normalizes declarations, dropping the space after
// each colon (e.g. `opacity: 0.8;` becomes `opacity:0.8;`) while preserving
// the property/value pairs themselves.
const sanitized = sanitizeHtml(tableWithStyles);
expect(sanitized).toContain('style="opacity: 0.8;"');
expect(sanitized).toContain('style="text-align: left; padding-left: 0px;"');
expect(sanitized).toContain('style="text-align: right; padding-left: 16px;"');
expect(sanitized).toContain('style="opacity:0.8;"');
expect(sanitized).toContain('style="text-align:left; padding-left:0px;"');
expect(sanitized).toContain('style="text-align:right; padding-left:16px;"');
const data = [
['Metric', 'Value'],
@@ -172,10 +175,10 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
];
const html = tooltipHtml(data, 'Test Tooltip');
expect(html).toContain('style="opacity: 0.8;"');
expect(html).toContain('text-align: left');
expect(html).toContain('text-align: right');
expect(html).toContain('padding-left: 0px');
expect(html).toContain('padding-left: 16px');
expect(html).toContain('max-width: 300px');
expect(html).toContain('style="opacity:0.8;"');
expect(html).toContain('text-align:left');
expect(html).toContain('text-align:right');
expect(html).toContain('padding-left:0px');
expect(html).toContain('padding-left:16px');
expect(html).toContain('max-width:300px');
});

View File

@@ -19,7 +19,9 @@
import { getTimeFormatter } from '@superset-ui/core';
// Cal-Heatmap provides local timestamps. We subtract the offset so that utcFormat displays the correct local date.
// Cal-Heatmap provides local timestamps (UTC shifted by the browser's timezone
// offset). We subtract that offset so the formatter displays the correct UTC
// date regardless of the browser's timezone.
export const getFormattedUTCTime = (
ts: number | string,
timeFormat?: string,

View File

@@ -299,18 +299,23 @@ var CalHeatMap = function () {
// Takes the fetched "data" object as argument, must return a json object
// formatted like {timestamp:count, timestamp2:count2},
afterLoadData: function (timestamps) {
// See https://github.com/wa0x6e/cal-heatmap/issues/126#issuecomment-373301803
const stdTimezoneOffset = date => {
const jan = new Date(date.getFullYear(), 0, 1);
const jul = new Date(date.getFullYear(), 6, 1);
return Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset());
};
const offset = stdTimezoneOffset(new Date()) * 60;
// Use the DST-aware timezone offset for each individual timestamp so that
// every data point is shifted by its own local offset (not a fixed
// standard-time offset). This prevents data from landing in phantom hours
// during DST transitions and keeps the offset consistent with what
// getFormattedUTCTime undoes when formatting the tooltip.
//
// Around DST transitions two distinct UTC timestamps can shift to the
// same adjusted key (e.g. the "spring forward" hour that doesn't exist
// locally). Accumulate values on collision so no datapoints are silently
// dropped in hourly/minutely views.
let results = {};
for (let timestamp in timestamps) {
const value = timestamps[timestamp];
timestamp = parseInt(timestamp, 10);
results[timestamp + offset] = value;
const ts = parseInt(timestamp, 10);
const offset = new Date(ts * 1000).getTimezoneOffset() * 60;
const adjustedTs = ts + offset;
results[adjustedTs] = (results[adjustedTs] || 0) + value;
}
return results;
},
@@ -4005,6 +4010,10 @@ function mergeRecursive(obj1, obj2) {
/*jshint forin:false */
for (var p in obj2) {
// Skip keys that could pollute the object prototype.
if (p === '__proto__' || p === 'constructor' || p === 'prototype') {
continue;
}
try {
// Property in destination object set; update its value.
if (obj2[p].constructor === Object) {

View File

@@ -19,78 +19,71 @@
import { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
describe('getFormattedUTCTime', () => {
test('formats local timestamp for display as UTC date', () => {
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const formattedTime = getFormattedUTCTime(
localTimestamp,
'%Y-%m-%d %H:%M:%S',
);
test('getFormattedUTCTime formats local timestamp for display as UTC date', () => {
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
// Cal-Heatmap's afterLoadData adjusts timestamps similarly, so
// getFormattedUTCTime receives already-adjusted timestamps and
// formats them directly. The date component should be correct.
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
expect(formattedTime).toEqual('2015-01-01 00:00:00');
});
expect(formattedTime).toEqual('2015-01-01');
});
describe('convertUTCTimestampToLocal', () => {
test('adjusts timestamp so local Date shows UTC date', () => {
const utcTimestamp = 1704067200000;
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const adjustedDate = new Date(adjustedTimestamp);
test('convertUTCTimestampToLocal adjusts timestamp so local Date shows UTC date', () => {
const utcTimestamp = 1704067200000;
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const adjustedDate = new Date(adjustedTimestamp);
expect(adjustedDate.getFullYear()).toEqual(2024);
expect(adjustedDate.getMonth()).toEqual(0);
expect(adjustedDate.getDate()).toEqual(1);
});
test('handles month boundaries', () => {
const utcTimestamp = 1706745600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
expect(adjustedDate.getFullYear()).toEqual(2024);
expect(adjustedDate.getMonth()).toEqual(1);
expect(adjustedDate.getDate()).toEqual(1);
});
test('handles year boundaries', () => {
const utcTimestamp = 1735689600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
expect(adjustedDate.getFullYear()).toEqual(2025);
expect(adjustedDate.getMonth()).toEqual(0);
expect(adjustedDate.getDate()).toEqual(1);
});
test('adds timezone offset to timestamp', () => {
const utcTimestamp = 1704067200000;
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const expectedOffset =
new Date(utcTimestamp).getTimezoneOffset() * 60 * 1000;
expect(adjustedTimestamp - utcTimestamp).toEqual(expectedOffset);
});
expect(adjustedDate.getFullYear()).toEqual(2024);
expect(adjustedDate.getMonth()).toEqual(0);
expect(adjustedDate.getDate()).toEqual(1);
});
describe('integration', () => {
test('fixes timezone bug for CalHeatMap', () => {
const febFirst2024UTC = 1706745600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(febFirst2024UTC));
test('convertUTCTimestampToLocal handles month boundaries', () => {
const utcTimestamp = 1706745600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
expect(adjustedDate.getMonth()).toEqual(1);
expect(adjustedDate.getDate()).toEqual(1);
});
test('both functions work together to display dates correctly', () => {
const utcTimestamp = 1704067200000;
// convertUTCTimestampToLocal adjusts UTC for Cal-Heatmap (which interprets as local)
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const calHeatmapDate = new Date(localTimestamp);
expect(calHeatmapDate.getMonth()).toEqual(0);
expect(calHeatmapDate.getDate()).toEqual(1);
// getFormattedUTCTime receives LOCAL timestamp (from Cal-Heatmap) and formats it
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
expect(formattedTime).toContain('2024-01-01');
});
expect(adjustedDate.getFullYear()).toEqual(2024);
expect(adjustedDate.getMonth()).toEqual(1);
expect(adjustedDate.getDate()).toEqual(1);
});
test('convertUTCTimestampToLocal handles year boundaries', () => {
const utcTimestamp = 1735689600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
expect(adjustedDate.getFullYear()).toEqual(2025);
expect(adjustedDate.getMonth()).toEqual(0);
expect(adjustedDate.getDate()).toEqual(1);
});
test('convertUTCTimestampToLocal adds timezone offset to timestamp', () => {
const utcTimestamp = 1704067200000;
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const expectedOffset = new Date(utcTimestamp).getTimezoneOffset() * 60 * 1000;
expect(adjustedTimestamp - utcTimestamp).toEqual(expectedOffset);
});
test('convertUTCTimestampToLocal fixes timezone bug for CalHeatMap', () => {
const febFirst2024UTC = 1706745600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(febFirst2024UTC));
expect(adjustedDate.getMonth()).toEqual(1);
expect(adjustedDate.getDate()).toEqual(1);
});
test('convertUTCTimestampToLocal and getFormattedUTCTime work together to display dates correctly', () => {
const utcTimestamp = 1704067200000;
// convertUTCTimestampToLocal adjusts UTC for Cal-Heatmap (which interprets as local)
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const calHeatmapDate = new Date(localTimestamp);
expect(calHeatmapDate.getMonth()).toEqual(0);
expect(calHeatmapDate.getDate()).toEqual(1);
// getFormattedUTCTime receives LOCAL timestamp (from Cal-Heatmap) and formats it
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
expect(formattedTime).toContain('2024-01-01');
});

View File

@@ -27,6 +27,7 @@ import {
getNumberFormatter,
getTimeFormatter,
CategoricalColorNamespace,
sanitizeHtml,
} from '@superset-ui/core';
interface PartitionDataNode {
@@ -345,7 +346,7 @@ function Icicle(element: HTMLElement, props: IcicleProps): void {
t += '</tbody></table>';
const [tipX, tipY] = d3.mouse(element);
tip
.html(t)
.html(sanitizeHtml(t))
.style('left', `${tipX + 15}px`)
.style('top', `${tipY}px`);
}

View File

@@ -27,6 +27,7 @@ import {
getTimeFormatter,
getNumberFormatter,
CategoricalColorNamespace,
sanitizeHtml,
} from '@superset-ui/core';
interface RoseDataEntry {
@@ -146,24 +147,32 @@ function Rose(element: HTMLElement, props: RoseProps): void {
function legendData(adatum: RoseData) {
return adatum[times[0]].map((v: RoseDataEntry, i: number) => ({
disabled: state.disabled[i],
// Keep the raw name as `key` so it matches the value used for arc
// fills (colorFn is called with d.name on arcs and d.key on the
// legend). nvd3-fork's legend renders `key` via .text(), so the
// raw value is escaped at the DOM sink.
key: v.name,
}));
}
function tooltipData(d: ArcDatum, i: number, adatum: RoseData) {
const timeIndex = Math.floor(d.arcId / numGroups);
// nvd3-fork's nv.models.tooltip renders the `key` strings via .html(),
// so any HTML in user-controlled column values would execute. Pass the
// keys through sanitizeHtml to strip dangerous markup while preserving
// legitimate text content.
const series = useRichTooltip
? adatum[times[timeIndex]]
.filter(v => !state.disabled[v.id % numGroups])
.map(v => ({
key: v.name,
key: sanitizeHtml(v.name),
value: v.value,
color: colorFn(v.name, sliceId),
highlight: v.id === d.arcId,
}))
: [
{
key: d.name,
key: sanitizeHtml(d.name),
value: d.val,
color: colorFn(d.name, sliceId),
},

View File

@@ -150,7 +150,8 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
fillColor: colorFn(d.name, sliceId),
}));
} else {
const rawExtents = d3Extent(filteredData, d => d.m1);
const colorableData = filteredData.filter(d => d.m1 != null);
const rawExtents = d3Extent(colorableData, d => d.m1);
const extents: [number, number] =
rawExtents[0] != null && rawExtents[1] != null
? [rawExtents[0], rawExtents[1]]
@@ -163,7 +164,7 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
processedData = filteredData.map(d => ({
...d,
radius: radiusScale(Math.sqrt(d.m2)),
fillColor: colorFn(d.m1) ?? theme.colorBorder,
fillColor: d.m1 != null ? colorFn(d.m1) ?? theme.colorBorder : theme.colorBorder,
}));
}

View File

@@ -511,7 +511,7 @@ test('assigns fill colors from sequential scheme when colorBy is metric', () =>
expect(data.CAN.fillColor).toMatch(/^(#|rgb)/);
});
test('falls back to theme.colorBorder when metric values are null', () => {
test('renders countries with null metric as no-data fill when colorBy is metric', () => {
WorldMap(container, {
...baseProps,
colorBy: ColorBy.Metric,
@@ -519,17 +519,66 @@ test('falls back to theme.colorBorder when metric values are null', () => {
{
country: 'USA',
name: 'United States',
m1: null as unknown as number,
m1: 100,
m2: 200,
code: 'US',
latitude: 37.0902,
longitude: -95.7129,
},
{
country: 'CAN',
name: 'Canada',
m1: null as unknown as number,
m2: 100,
code: 'CA',
latitude: 56.1304,
longitude: -106.3468,
},
],
} as any);
});
const data = lastDatamapConfig?.data as Record<string, { fillColor: string }>;
expect(data.USA.fillColor).toBe('#e0e0e0');
const data = lastDatamapConfig?.data as Record<
string,
WorldMapDataEntry & { fillColor: string }
>;
expect(data).toHaveProperty('USA');
expect(data).toHaveProperty('CAN');
expect(data.CAN.fillColor).toBe('#e0e0e0');
});
test('renders countries with zero metric using the color scale when colorBy is metric', () => {
WorldMap(container, {
...baseProps,
colorBy: ColorBy.Metric,
data: [
{
country: 'USA',
name: 'United States',
m1: 100,
m2: 200,
code: 'US',
latitude: 37.0902,
longitude: -95.7129,
},
{
country: 'MEX',
name: 'Mexico',
m1: 0,
m2: 50,
code: 'MX',
latitude: 23.6345,
longitude: -102.5528,
},
],
});
const data = lastDatamapConfig?.data as Record<
string,
WorldMapDataEntry & { fillColor: string }
>;
expect(data).toHaveProperty('MEX');
expect(data.MEX.fillColor).toMatch(/^(#|rgb)/);
expect(data.MEX.fillColor).not.toBe('#e0e0e0');
});
test('does not throw with empty data and metric coloring', () => {

View File

@@ -152,7 +152,7 @@ export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) {
d.series.forEach((series, i) => {
const yFormatter = yFormatters[i];
const key = getFormattedKey(series.key, false);
const key = getFormattedKey(series.key, true);
tooltip +=
"<tr><td class='legend-color-guide'>" +
`<div style="background-color: ${series.color};"></div></td>` +
@@ -162,7 +162,7 @@ export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) {
tooltip += '</tbody></table>';
return tooltip;
return dompurify.sanitize(tooltip);
}
export function generateTimePivotTooltip(d, xFormatter, yFormatter) {
@@ -223,7 +223,7 @@ export function generateBubbleTooltipContent({
s += createHTMLRow(getLabel(sizeField), sizeFormatter(point.size));
s += '</table>';
return s;
return dompurify.sanitize(s);
}
// shouldRemove indicates whether the nvtooltips should be removed from the DOM
@@ -262,35 +262,42 @@ export function wrapTooltip(chart) {
: chart;
const tooltipGeneratorFunc = tooltipLayer.tooltip.contentGenerator();
tooltipLayer.tooltip.contentGenerator(d => {
let tooltip = `<div>`;
tooltip += tooltipGeneratorFunc(d);
tooltip += '</div>';
return tooltip;
// The nvd3-fork default contentGenerator builds tooltip HTML with
// unescaped series keys (and feeds them into the tooltip's `.html()`
// sink at render time). Run the final string through DOMPurify so
// charts that do NOT install a custom contentGenerator (Line, Bar,
// Area, Pie, BoxPlot, etc.) cannot execute stored payloads in
// column or series names. Custom contentGenerators set elsewhere in
// this module already return sanitized output, making this a
// belt-and-braces wrap.
const tooltip = `<div>${tooltipGeneratorFunc(d)}</div>`;
return dompurify.sanitize(tooltip);
});
}
// Builds the sanitized HTML for an annotation layer's tooltip. Title and
// description values come from the annotation data source, so the output is
// run through dompurify before being inserted into the DOM by d3-tip.
export function generateAnnotationTooltipContent(layer, d) {
const title =
d[layer.titleColumn] && d[layer.titleColumn].length > 0
? `${d[layer.titleColumn]} - ${layer.name}`
: layer.name;
const body = Array.isArray(layer.descriptionColumns)
? layer.descriptionColumns.map(c => d[c])
: Object.values(d);
return dompurify.sanitize(
`<div><strong>${title}</strong></div><br/><div>${body.join(', ')}</div>`,
);
}
export function tipFactory(layer) {
return d3tip()
.attr('class', `d3-tip ${layer.annotationTipClass || ''}`)
.direction('n')
.offset([-5, 0])
.html(d => {
if (!d) {
return '';
}
const title =
d[layer.titleColumn] && d[layer.titleColumn].length > 0
? `${d[layer.titleColumn]} - ${layer.name}`
: layer.name;
const body = Array.isArray(layer.descriptionColumns)
? layer.descriptionColumns.map(c => d[c])
: Object.values(d);
return `<div><strong>${title}</strong></div><br/><div>${body.join(
', ',
)}</div>`;
});
.html(d => (d ? generateAnnotationTooltipContent(layer, d) : ''));
}
export function getMaxLabelSize(svg, axisClass) {

View File

@@ -24,8 +24,12 @@ import {
import {
computeYDomain,
generateAnnotationTooltipContent,
generateBubbleTooltipContent,
generateMultiLineTooltipContent,
getTimeOrNumberFormatter,
formatLabel,
tipFactory,
} from '../src/utils';
const DATA = [
@@ -122,6 +126,42 @@ describe('nvd3/utils', () => {
);
});
describe('generateMultiLineTooltipContent()', () => {
const identity = (value: any) => value;
test('renders the series key in the tooltip markup', () => {
const tooltip = generateMultiLineTooltipContent(
{
value: 'x-value',
series: [{ key: 'Region A', color: '#fff', value: 1 }],
},
identity,
[identity],
);
expect(tooltip).toContain('Region A');
});
test('strips a script payload from a malicious series key', () => {
const tooltip = generateMultiLineTooltipContent(
{
value: 'x-value',
series: [
{
key: '<img src=x onerror="alert(1)">',
color: '#fff',
value: 1,
},
],
},
identity,
[identity],
);
// DOMPurify removes the event handler that would execute on render.
expect(tooltip).not.toContain('onerror');
expect(tooltip).not.toContain('alert(1)');
});
});
describe('getTimeOrNumberFormatter(format)', () => {
test('is a function', () => {
expect(typeof getTimeOrNumberFormatter).toBe('function');
@@ -181,4 +221,138 @@ describe('nvd3/utils', () => {
]);
});
});
// ------------------------------------------------------------------
// Tooltip HTML sanitisation (XSS regression).
// Each helper below feeds user-controlled column values into a
// d3 / nvd3 .html() sink; the sanitised return must strip dangerous
// markup so a stored payload cannot execute on hover.
// ------------------------------------------------------------------
describe('generateBubbleTooltipContent() sanitises user input', () => {
test('strips <script> from the entity column', () => {
const html = generateBubbleTooltipContent({
point: {
color: 'red',
group: 'g',
entity: '<script>alert(1)</script>',
x: 1,
y: 2,
size: 3,
},
entity: 'entity',
xField: 'x',
yField: 'y',
sizeField: 'size',
xFormatter: (v: number) => String(v),
yFormatter: (v: number) => String(v),
sizeFormatter: (v: number) => String(v),
});
expect(html).not.toMatch(/<script/i);
expect(html).not.toMatch(/onerror=/i);
});
test('strips <img onerror> injected via the group column', () => {
const html = generateBubbleTooltipContent({
point: {
color: 'red',
group: '<img src=x onerror=alert(1)>',
entity: 'safe',
x: 1,
y: 2,
size: 3,
},
entity: 'entity',
xField: 'x',
yField: 'y',
sizeField: 'size',
xFormatter: (v: number) => String(v),
yFormatter: (v: number) => String(v),
sizeFormatter: (v: number) => String(v),
});
expect(html).not.toMatch(/onerror/i);
});
});
describe('generateMultiLineTooltipContent() sanitises user input', () => {
test('strips <script> from a series key', () => {
const html = generateMultiLineTooltipContent(
{
value: 0,
series: [
{
key: '<script>alert(1)</script>',
color: 'red',
value: 1,
},
],
},
(v: number) => String(v),
[(v: number) => String(v)],
);
expect(html).not.toMatch(/<script/i);
});
});
describe('tipFactory() sanitises annotation columns', () => {
test('strips <script> from a description column value', () => {
const tip = tipFactory({
annotationTipClass: 'foo',
titleColumn: 'title',
descriptionColumns: ['desc'],
name: 'layer',
});
// d3-tip's .html(fn) stores the callback as the renderer; invoke
// it directly to assert the sanitised output.
const datum = {
title: 'normal',
desc: '<script>alert(1)</script>payload',
};
const html = tip.html()(datum);
expect(html).not.toMatch(/<script/i);
expect(html).toContain('payload');
});
});
describe('generateAnnotationTooltipContent()', () => {
const layer = {
name: 'My annotations',
titleColumn: 'title',
descriptionColumns: ['description'],
};
test('renders the annotation title and description', () => {
const html = generateAnnotationTooltipContent(layer, {
title: 'Release',
description: 'Shipped v1',
});
expect(html).toContain('Release - My annotations');
expect(html).toContain('Shipped v1');
});
test('falls back to the layer name when the title column is empty', () => {
const html = generateAnnotationTooltipContent(layer, {
title: '',
description: 'Shipped v1',
});
expect(html).toContain('My annotations');
});
test('strips an event-handler payload from the title column', () => {
const html = generateAnnotationTooltipContent(layer, {
title: '<img src=x onerror="alert(1)">',
description: 'ok',
});
expect(html).not.toContain('onerror');
expect(html).not.toContain('alert(1)');
});
test('strips a script payload from a description column', () => {
const html = generateAnnotationTooltipContent(layer, {
title: 'Release',
description: '<script>alert(document.cookie)</script>',
});
expect(html).not.toContain('<script>');
});
});
});

View File

@@ -86,6 +86,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
onChartStateChange,
chartState,
metricSqlExpressions,
showNumberedColumn,
} = props;
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
@@ -230,6 +231,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
: (columns as InputColumn[]),
data,
serverPagination,
serverPaginationData,
serverPageLength,
showNumberedColumn: showNumberedColumn && !emitCrossFilters,
isRawRecords,
defaultAlignPN: alignPositiveNegative,
showCellBars,

View File

@@ -42,3 +42,5 @@ export const FILTER_CONDITION_BODY_INDEX = {
FIRST: 0,
SECOND: 1,
} as const;
export const ROW_NUMBER_COL_ID = '__row_number__';

View File

@@ -281,11 +281,7 @@ const config: ControlPanelConfig = {
{ controls, datasource, form_data }: ControlPanelState,
controlState: ControlState,
) => ({
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
? (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
)
: datasource?.columns,
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
// current active adhoc metrics
selectedMetrics:
@@ -500,6 +496,18 @@ const config: ControlPanelConfig = {
label: t('Visual formatting'),
expanded: true,
controlSetRows: [
[
{
name: 'show_numbered_column',
config: {
type: 'CheckboxControl',
label: t('Add numbered column'),
renderTrigger: true,
default: false,
description: t('Whether to display the numbered column'),
},
},
],
[
{
name: 'column_config',

View File

@@ -216,7 +216,13 @@ function convertFilterToSQL(
if (conditions.length === 0) return null;
if (conditions.length === 1) return conditions[0];
return `(${conditions.join(` ${filter.operator} `)})`;
// The join operator is interpolated into raw SQL, so only allow the two
// boolean connectors AG Grid produces.
const joinOperator = String(filter.operator).toUpperCase();
if (joinOperator !== 'AND' && joinOperator !== 'OR') {
return null;
}
return `(${conditions.join(` ${joinOperator} `)})`;
}
// Handle blank/notBlank operators for all filter types
@@ -263,20 +269,26 @@ function convertFilterToSQL(
filter.filter !== undefined &&
filter.type
) {
// Number values are interpolated into the clause without quoting, so they
// must be finite numbers. Coerce and skip the filter if it is not numeric.
const numericValue = Number(filter.filter);
if (!Number.isFinite(numericValue)) {
return null;
}
// Map number filter types to SQL operators
switch (filter.type) {
case FILTER_OPERATORS.EQUALS:
return `${colId} ${SQL_OPERATORS.EQUALS} ${filter.filter}`;
return `${colId} ${SQL_OPERATORS.EQUALS} ${numericValue}`;
case FILTER_OPERATORS.NOT_EQUAL:
return `${colId} ${SQL_OPERATORS.NOT_EQUALS} ${filter.filter}`;
return `${colId} ${SQL_OPERATORS.NOT_EQUALS} ${numericValue}`;
case FILTER_OPERATORS.LESS_THAN:
return `${colId} ${SQL_OPERATORS.LESS_THAN} ${filter.filter}`;
return `${colId} ${SQL_OPERATORS.LESS_THAN} ${numericValue}`;
case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
return `${colId} ${SQL_OPERATORS.LESS_THAN_OR_EQUAL} ${filter.filter}`;
return `${colId} ${SQL_OPERATORS.LESS_THAN_OR_EQUAL} ${numericValue}`;
case FILTER_OPERATORS.GREATER_THAN:
return `${colId} ${SQL_OPERATORS.GREATER_THAN} ${filter.filter}`;
return `${colId} ${SQL_OPERATORS.GREATER_THAN} ${numericValue}`;
case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
return `${colId} ${SQL_OPERATORS.GREATER_THAN_OR_EQUAL} ${filter.filter}`;
return `${colId} ${SQL_OPERATORS.GREATER_THAN_OR_EQUAL} ${numericValue}`;
default:
return null;
}
@@ -314,7 +326,9 @@ export function convertFilterModel(
return undefined;
}
const sqlClauses: Record<string, string> = {};
// Null-prototype object: column ids come from the (user-influenced) filter
// model and are used as keys here, so avoid prototype-chain keys.
const sqlClauses: Record<string, string> = Object.create(null);
Object.entries(filterModel).forEach(([colId, filter]) => {
const sqlClause = convertFilterToSQL(colId, filter);

View File

@@ -419,5 +419,11 @@ export const StyledChartContainer = styled.div<{
color: ${theme.colorTextQuaternary};
}
}
.ag-header-center {
.ag-header-cell-label {
justify-content: center;
}
}
`}
`;

View File

@@ -507,6 +507,7 @@ const transformProps = (
conditional_formatting: conditionalFormatting,
comparison_color_enabled: comparisonColorEnabled = false,
comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green,
show_numbered_column: showNumberedColumn = false,
} = formData;
const allowRearrangeColumns = true;
@@ -785,6 +786,7 @@ const transformProps = (
metricSqlExpressions,
chartState,
onChartStateChange,
showNumberedColumn,
};
};

View File

@@ -81,6 +81,7 @@ export type TableChartFormData = QueryFormData & {
time_grain_sqla?: TimeGranularity;
column_config?: Record<string, TableColumnConfig>;
allow_rearrange_columns?: boolean;
show_numbered_column?: boolean;
};
export interface TableChartProps extends ChartProps {
@@ -131,6 +132,7 @@ export interface AgGridTableChartTransformedProps<
metricSqlExpressions: Record<string, string>;
onChartStateChange?: (chartState: JsonObject) => void;
chartState?: AgGridChartState;
showNumberedColumn: boolean;
}
export interface SortState {

View File

@@ -17,9 +17,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ColDef } from '@superset-ui/core/components/ThemedAgGridReact';
import { useCallback, useMemo } from 'react';
import { DataRecord, DataRecordValue } from '@superset-ui/core';
import { DataRecord, DataRecordValue, JsonObject } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useTheme } from '@apache-superset/core/theme';
import { ColorFormatters } from '@superset-ui/chart-controls';
@@ -41,7 +42,7 @@ import { getAggFunc } from './getAggFunc';
import { TextCellRenderer } from '../renderers/TextCellRenderer';
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
import CustomHeader from '../AgGridTable/components/CustomHeader';
import { NOOP_FILTER_COMPARATOR } from '../consts';
import { NOOP_FILTER_COMPARATOR, ROW_NUMBER_COL_ID } from '../consts';
import { valueFormatter, valueGetter } from './formatValue';
import getCellStyle from './getCellStyle';
@@ -53,6 +54,9 @@ type UseColDefsProps = {
columns: InputColumn[];
data: InputData[];
serverPagination: boolean;
serverPaginationData: JsonObject;
serverPageLength: number;
showNumberedColumn: boolean;
isRawRecords: boolean;
defaultAlignPN: boolean;
showCellBars: boolean;
@@ -216,6 +220,9 @@ export const useColDefs = ({
columns,
data,
serverPagination,
serverPaginationData,
serverPageLength,
showNumberedColumn,
isRawRecords,
defaultAlignPN,
showCellBars,
@@ -459,5 +466,61 @@ export const useColDefs = ({
}, []);
}, [stringifiedCols, getCommonColProps]);
return colDefs;
const rawPageSize = serverPaginationData?.pageSize ?? serverPageLength;
const pageSize = rawPageSize && rawPageSize > 0 ? rawPageSize : data.length;
const currentPage = serverPaginationData?.currentPage ?? 0;
const maxVisibleRowNumber = serverPagination
? currentPage * pageSize + data.length
: data.length;
const rowIndexLength = `${Math.max(maxVisibleRowNumber, 1)}`.length;
const rowNumberCol = useMemo<ColDef>(
() => ({
headerName: t('№'),
headerClass: 'ag-header-center',
field: ROW_NUMBER_COL_ID,
valueGetter: params => {
if (params.node?.rowPinned != null) return '';
if (serverPagination && serverPaginationData) {
return currentPage * pageSize + (params.node?.rowIndex ?? 0) + 1;
}
return (params.node?.rowIndex ?? 0) + 1;
},
headerStyle: {
backgroundColor: theme.colorFillTertiary,
fontSize: '1em',
color: theme.colorTextTertiary,
},
width: 30 + rowIndexLength * 6,
minWidth: 30 + rowIndexLength * 6,
sortable: false,
filter: false,
pinned: 'left' as const,
lockPosition: true,
suppressNavigable: true,
resizable: false,
suppressMovable: true,
suppressSizeToFit: true,
cellStyle: {
backgroundColor: theme.colorFillTertiary,
padding: '0',
textAlign: 'center',
fontSize: '0.9em',
color: theme.colorTextTertiary,
},
}),
[
currentPage,
pageSize,
rowIndexLength,
serverPagination,
serverPaginationData,
theme.colorFillTertiary,
theme.colorTextTertiary,
],
);
return useMemo(
() => (showNumberedColumn ? [rowNumberCol, ...colDefs] : colDefs),
[showNumberedColumn, rowNumberCol, colDefs],
);
};

View File

@@ -19,6 +19,7 @@
import { GenericDataType } from '@apache-superset/core/common';
import { QueryFormData } from '@superset-ui/core';
import {
ColumnMeta,
Dataset,
isCustomControlItem,
ControlConfig,
@@ -45,6 +46,32 @@ const findConditionalFormattingControl = (): ControlConfig | null => {
return null;
};
const findMetricsMapStateToProps = ():
| ControlConfig['mapStateToProps']
| null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
for (const row of section.controlSetRows) {
for (const control of row) {
if (
control &&
typeof control === 'object' &&
'name' in control &&
(control as { name: string }).name === 'metrics' &&
'override' in control
) {
return (
control as {
override: { mapStateToProps: ControlConfig['mapStateToProps'] };
}
).override.mapStateToProps;
}
}
}
}
return null;
};
const createMockControlState = (value: string[] | undefined): ControlState => ({
type: 'SelectControl',
value,
@@ -206,3 +233,47 @@ test('static extraColorChoices removed from config', () => {
expect(controlConfig?.extraColorChoices).toBeUndefined();
});
const createMockExploreWithColumns = (
columns: Partial<ColumnMeta>[],
): ControlPanelState => ({
slice: { slice_id: 123 },
datasource: {
verbose_map: {},
columns,
metrics: [],
} as Partial<Dataset> as Dataset,
controls: {},
form_data: {
datasource: 'test',
viz_type: 'table',
} as QueryFormData,
common: {},
metadata: {},
});
const createMockMetricsControlState = (): ControlState => ({
type: 'MetricsControl',
value: [],
label: '',
default: undefined,
renderTrigger: false,
});
test('metrics control includes non-filterable columns', () => {
const mapStateToProps = findMetricsMapStateToProps();
expect(mapStateToProps).toBeTruthy();
const explore = createMockExploreWithColumns([
{ column_name: 'filterable_col', filterable: true },
{ column_name: 'non_filterable_col', filterable: false },
]);
const result = mapStateToProps!(explore, createMockMetricsControlState());
expect(result.columns).toEqual(
expect.arrayContaining([
expect.objectContaining({ column_name: 'filterable_col' }),
expect.objectContaining({ column_name: 'non_filterable_col' }),
]),
);
});

View File

@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { convertFilterModel } from '../src/stateConversion';
describe('convertFilterModel', () => {
test('emits a clause for a valid numeric comparison filter', () => {
const result = convertFilterModel({
revenue: { filterType: 'number', type: 'greaterThan', filter: 100 },
} as any);
expect(result?.sqlClauses?.revenue).toBe('revenue > 100');
});
test('drops a number filter whose value is not numeric', () => {
const result = convertFilterModel({
revenue: { filterType: 'number', type: 'equals', filter: '1 OR 1=1' },
} as any);
expect(result).toBeUndefined();
});
test('joins conditions with a valid boolean operator', () => {
const result = convertFilterModel({
revenue: {
filterType: 'number',
operator: 'AND',
condition1: { filterType: 'number', type: 'greaterThan', filter: 1 },
condition2: { filterType: 'number', type: 'lessThan', filter: 9 },
},
} as any);
expect(result?.sqlClauses?.revenue).toBe('(revenue > 1 AND revenue < 9)');
});
test('drops a compound filter whose join operator is not AND/OR', () => {
const result = convertFilterModel({
revenue: {
filterType: 'number',
operator: 'AND 1=1) OR (1=1',
condition1: { filterType: 'number', type: 'greaterThan', filter: 1 },
condition2: { filterType: 'number', type: 'lessThan', filter: 9 },
},
} as any);
expect(result).toBeUndefined();
});
test('stores clauses on a null-prototype map (prototype-safe column ids)', () => {
const result = convertFilterModel({
constructor: { filterType: 'number', type: 'equals', filter: 5 },
} as any);
// The map has no prototype, so a column id like "constructor" is just data.
expect(Object.getPrototypeOf(result?.sqlClauses)).toBeNull();
expect(result?.sqlClauses?.constructor).toBe('constructor = 5');
});
});

View File

@@ -28,6 +28,7 @@ import tinycolor from 'tinycolor2';
import { createElement, type ComponentProps, ReactNode } from 'react';
import { useColDefs } from '../../src/utils/useColDefs';
import { InputColumn } from '../../src/types';
import { ROW_NUMBER_COL_ID } from '../../src/consts';
type TestCellStyleFunc = (params: unknown) => unknown;
@@ -122,6 +123,9 @@ const defaultThemeWrapper = makeThemeWrapper(supersetTheme);
const defaultProps = {
data: [{ test_col: 'value' }],
serverPagination: false,
serverPaginationData: {},
serverPageLength: 0,
showNumberedColumn: false,
isRawRecords: true,
defaultAlignPN: false,
showCellBars: false,
@@ -136,6 +140,12 @@ const defaultProps = {
slice_id: 1,
};
const basePropsNumericColumns = {
...defaultProps,
columns: [makeColumn({ key: 'a', label: 'A' })],
data: [{ a: 1 }, { a: 2 }, { a: 3 }],
};
test('boolean columns use agCheckboxCellRenderer', () => {
const booleanCol = makeColumn({
key: 'is_active',
@@ -828,3 +838,233 @@ test('cellStyle respects explicit horizontal alignment overrides', () => {
textAlign: 'center',
});
});
test('is not added when showNumberedColumn is false', () => {
const { result } = renderHook(
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: false }),
{ wrapper: defaultThemeWrapper },
);
expect(result.current.some(col => col.field === ROW_NUMBER_COL_ID)).toBe(
false,
);
expect(result.current.length).toBe(1);
});
test('is added as first column when showNumberedColumn is true', () => {
const { result } = renderHook(
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
{ wrapper: defaultThemeWrapper },
);
expect(result.current[0].field).toBe(ROW_NUMBER_COL_ID);
expect(result.current[0].headerName).toBe('№');
expect(result.current.length).toBe(2);
});
test('width defaults to 36 when maxVisibleRowNumber is 0 (empty data)', () => {
const emptyProps = {
...basePropsNumericColumns,
data: [],
showNumberedColumn: true,
};
const { result } = renderHook(() => useColDefs(emptyProps), {
wrapper: defaultThemeWrapper,
});
expect(result.current[0].width).toBe(36);
});
test('width uses server pagination row count when available', () => {
const serverProps = {
...basePropsNumericColumns,
serverPagination: true,
serverPaginationData: { currentPage: 0, pageSize: 5 },
data: [{ a: 1 }, { a: 2 }],
showNumberedColumn: true,
};
const { result } = renderHook(() => useColDefs(serverProps), {
wrapper: defaultThemeWrapper,
});
expect(result.current[0].width).toBe(36);
const laterPageProps = {
...serverProps,
serverPaginationData: { currentPage: 3, pageSize: 5 },
data: [{ a: 21 }, { a: 22 }],
};
const { result: resultLater } = renderHook(() => useColDefs(laterPageProps), {
wrapper: defaultThemeWrapper,
});
expect(resultLater.current[0].width).toBe(42);
});
test('valueGetter returns row numbers without server pagination', () => {
const { result } = renderHook(
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
const { valueGetter } = colDef;
expect(valueGetter).toBeDefined();
expect(typeof valueGetter).toBe('function');
const getter = valueGetter as (params: {
node?: { rowIndex?: number };
data?: Record<string, unknown>;
}) => number;
const params = (rowIndex: number) => ({
node: { rowIndex },
data: basePropsNumericColumns.data[rowIndex],
});
expect(getter(params(0))).toBe(1);
expect(getter(params(1))).toBe(2);
expect(getter(params(2))).toBe(3);
});
test('valueGetter respects server pagination', () => {
const serverProps = {
...basePropsNumericColumns,
serverPagination: true,
serverPaginationData: { currentPage: 2, pageSize: 5 },
data: [{ a: 11 }, { a: 12 }],
};
const { result } = renderHook(
() => useColDefs({ ...serverProps, showNumberedColumn: true }),
{ wrapper: defaultThemeWrapper },
);
const { valueGetter } = result.current[0];
expect(typeof valueGetter).toBe('function');
const getter = valueGetter as (params: {
node?: { rowIndex?: number };
data?: Record<string, unknown>;
}) => number;
expect(getter({ node: { rowIndex: 0 } })).toBe(11);
expect(getter({ node: { rowIndex: 1 } })).toBe(12);
});
test('has correct static column properties', () => {
const { result } = renderHook(
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
expect(colDef.sortable).toBe(false);
expect(colDef.filter).toBe(false);
expect(colDef.pinned).toBe('left');
expect(colDef.lockPosition).toBe(true);
expect(colDef.suppressNavigable).toBe(true);
expect(colDef.suppressSizeToFit).toBe(true);
expect(colDef.resizable).toBe(false);
expect(colDef.suppressMovable).toBe(true);
expect(colDef.headerStyle).toBeDefined();
expect(colDef.cellStyle).toBeDefined();
});
test('pageSize priority chain works correctly', () => {
const props1 = {
...basePropsNumericColumns,
serverPagination: true,
serverPaginationData: { currentPage: 2, pageSize: 10 },
serverPageLength: 5,
data: Array.from({ length: 3 }, () => ({ a: 1 })),
showNumberedColumn: true,
};
const { result: res1 } = renderHook(() => useColDefs(props1), {
wrapper: defaultThemeWrapper,
});
expect(res1.current[0].width).toBe(42);
const props2 = {
...props1,
serverPaginationData: { currentPage: 2 },
serverPageLength: 20,
};
const { result: res2 } = renderHook(() => useColDefs(props2), {
wrapper: defaultThemeWrapper,
});
expect(res2.current[0].width).toBe(42);
});
test('column width adapts to max row number length in client mode', () => {
const base = {
...basePropsNumericColumns,
showNumberedColumn: true,
serverPagination: false,
};
const props9 = { ...base, data: Array.from({ length: 9 }, () => ({ a: 1 })) };
const { result: res9 } = renderHook(() => useColDefs(props9), {
wrapper: defaultThemeWrapper,
});
expect(res9.current[0].width).toBe(36);
const props10 = {
...base,
data: Array.from({ length: 10 }, () => ({ a: 1 })),
};
const { result: res10 } = renderHook(() => useColDefs(props10), {
wrapper: defaultThemeWrapper,
});
expect(res10.current[0].width).toBe(42);
const props100 = {
...base,
data: Array.from({ length: 100 }, () => ({ a: 1 })),
};
const { result: res100 } = renderHook(() => useColDefs(props100), {
wrapper: defaultThemeWrapper,
});
expect(res100.current[0].width).toBe(48);
});
test('row number column uses theme variables for styling', () => {
const { result } = renderHook(
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
expect(colDef.cellStyle).toMatchObject({
backgroundColor: expect.any(String),
padding: '0',
textAlign: 'center',
fontSize: '0.9em',
color: expect.any(String),
});
expect(colDef.headerStyle).toMatchObject({
backgroundColor: expect.any(String),
fontSize: '1em',
color: expect.any(String),
});
});
test('valueGetter handles missing node or rowIndex gracefully', () => {
const { result } = renderHook(
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
{ wrapper: defaultThemeWrapper },
);
const getter = result.current[0].valueGetter as (params: {
node?: { rowIndex?: number };
}) => number;
expect(getter({})).toBe(1);
expect(getter({ node: {} })).toBe(1);
const serverProps = {
...basePropsNumericColumns,
serverPagination: true,
serverPaginationData: { currentPage: 2, pageSize: 5 },
showNumberedColumn: true,
};
const { result: serverResult } = renderHook(() => useColDefs(serverProps), {
wrapper: defaultThemeWrapper,
});
const serverGetter = serverResult.current[0].valueGetter as (params: {
node?: { rowIndex?: number };
}) => number;
expect(serverGetter({})).toBe(2 * 5 + 1);
expect(serverGetter({ node: {} })).toBe(11);
});

View File

@@ -288,9 +288,7 @@ describe('BigNumberWithTrendline transformProps', () => {
height: 300,
queriesData: [
{
data: [
{ __timestamp: 1, value: 100 },
] as unknown as BigNumberDatum[],
data: [{ __timestamp: 1, value: 100 }] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: ['TEMPORAL', 'NUMERIC'],
},

View File

@@ -21,13 +21,24 @@ import { histogramOperator } from '@superset-ui/chart-controls';
import { HistogramFormData } from './types';
export default function buildQuery(formData: HistogramFormData) {
const { column, groupby = [] } = formData;
const { column, groupby = [], adhoc_filters } = formData;
const hasHavingFilter = (adhoc_filters ?? []).some(
(filter: { clause?: string }) => filter.clause === 'HAVING',
);
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
columns: [...groupby, column],
post_processing: [histogramOperator(formData, baseQueryObject)],
metrics: undefined,
metrics: hasHavingFilter
? [
{
expressionType: 'SQL' as const,
sqlExpression: 'COUNT(*)',
label: 'COUNT(*)',
},
]
: undefined,
},
]);
}

View File

@@ -284,8 +284,11 @@ function Echart(
// setOption(notMerge:true) replaces the dataZoom config, dropping any
// range the user has engaged. Preserve it across the call.
const previousZoom = notMerge
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
?.dataZoom
? (
chartRef.current?.getOption() as {
dataZoom?: DataZoomComponentOption[];
}
)?.dataZoom
: undefined;
chartRef.current?.setOption(themedEchartOptions, {
notMerge,

View File

@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import buildQuery from '../../src/Histogram/buildQuery';
import { HistogramFormData } from '../../src/Histogram/types';
const baseFormData: HistogramFormData = {
datasource: '5__table',
granularity_sqla: 'ds',
column: 'price',
groupby: [],
bins: 10,
viz_type: 'histogram',
cumulative: false,
normalize: false,
sliceId: 1,
showLegend: false,
showValue: false,
xAxisFormat: '',
xAxisTitle: '',
yAxisFormat: '',
yAxisTitle: '',
};
test('should build query with column and no metrics', () => {
const queryContext = buildQuery(baseFormData);
const [query] = queryContext.queries;
expect(query.columns).toContain('price');
expect(query.metrics).toBeUndefined();
});
test('should include groupby columns in query columns', () => {
const queryContext = buildQuery({ ...baseFormData, groupby: ['category'] });
const [query] = queryContext.queries;
expect(query.columns).toEqual(['category', 'price']);
});
test('Regression for #30330: HAVING-clause metric filters require aggregation in the query', () => {
/**
* buildQuery unconditionally sets metrics: undefined, which means any
* HAVING-clause adhoc_filter produces SQL with a HAVING clause but no
* GROUP BY or aggregated metric — invalid SQL that most databases reject.
*
* The fix should preserve (or synthesise) a metric so the HAVING clause
* has an aggregated value to filter on. This test asserts that desired
* behaviour: when a HAVING adhoc_filter is present, query.metrics must
* not be undefined or empty.
*/
const formDataWithHavingFilter: HistogramFormData = {
...baseFormData,
adhoc_filters: [
{
clause: 'HAVING',
expressionType: 'SQL',
sqlExpression: 'COUNT(*) > 5',
},
],
};
const queryContext = buildQuery(formDataWithHavingFilter);
const [query] = queryContext.queries;
// HAVING filters without aggregation produce invalid SQL.
// The query must include at least one metric when HAVING filters are present.
expect(query.metrics).toBeDefined();
expect((query.metrics as unknown[]).length).toBeGreaterThan(0);
});

View File

@@ -20,8 +20,6 @@ import {
ControlSetItem,
ExtraControlProps,
sharedControls,
Dataset,
ColumnMeta,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { ensureIsArray } from '@superset-ui/core';
@@ -35,11 +33,7 @@ const dndAllColumns: typeof sharedControls.groupby = {
mapStateToProps({ datasource, controls }, controlState) {
const newState: ExtraControlProps = {};
if (datasource) {
if (datasource?.columns[0]?.hasOwnProperty('filterable')) {
newState.options = (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
);
} else newState.options = datasource.columns;
newState.options = datasource.columns || [];
}
newState.queryMode = getQueryMode(controls);
newState.externalValidationErrors =

View File

@@ -21,8 +21,6 @@ import {
ControlSetItem,
ControlState,
sharedControls,
Dataset,
ColumnMeta,
defineSavedMetrics,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
@@ -77,11 +75,7 @@ export const metricsControlSetItem: ControlSetItem = {
{ controls, datasource, form_data }: ControlPanelState,
controlState: ControlState,
) => ({
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
? (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
)
: datasource?.columns,
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
// current active adhoc metrics
selectedMetrics:

View File

@@ -37,7 +37,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/types": "^7.29.0",
"@babel/types": "^7.29.7",
"@types/jest": "^30.0.0",
"jest": "^30.4.2"
}

View File

@@ -304,11 +304,7 @@ const config: ControlPanelConfig = {
{ controls, datasource, form_data }: ControlPanelState,
controlState: ControlState,
) => ({
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
? (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
)
: datasource?.columns,
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
// current active adhoc metrics
selectedMetrics:

View File

@@ -19,6 +19,7 @@
import { GenericDataType } from '@apache-superset/core/common';
import { QueryFormData } from '@superset-ui/core';
import {
ColumnMeta,
Dataset,
isCustomControlItem,
ControlConfig,
@@ -46,6 +47,32 @@ const findConditionalFormattingControl = (): ControlConfig | null => {
return null;
};
const findMetricsMapStateToProps = ():
| ControlConfig['mapStateToProps']
| null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
for (const row of section.controlSetRows) {
for (const control of row) {
if (
control &&
typeof control === 'object' &&
'name' in control &&
(control as { name: string }).name === 'metrics' &&
'override' in control
) {
return (
control as {
override: { mapStateToProps: ControlConfig['mapStateToProps'] };
}
).override.mapStateToProps;
}
}
}
}
return null;
};
const createMockControlState = (value: string[] | undefined): ControlState => ({
type: 'SelectControl',
value,
@@ -209,6 +236,50 @@ test('static extraColorChoices removed from config', () => {
expect(controlConfig?.extraColorChoices).toBeUndefined();
});
const createMockExploreWithColumns = (
columns: Partial<ColumnMeta>[],
): ControlPanelState => ({
slice: { slice_id: 123 },
datasource: {
verbose_map: {},
columns,
metrics: [],
} as Partial<Dataset> as Dataset,
controls: {},
form_data: {
datasource: 'test',
viz_type: 'table',
} as QueryFormData,
common: {},
metadata: {},
});
const createMockMetricsControlState = (): ControlState => ({
type: 'MetricsControl',
value: [],
label: '',
default: undefined,
renderTrigger: false,
});
test('metrics control includes non-filterable columns', () => {
const mapStateToProps = findMetricsMapStateToProps();
expect(mapStateToProps).toBeTruthy();
const explore = createMockExploreWithColumns([
{ column_name: 'filterable_col', filterable: true },
{ column_name: 'non_filterable_col', filterable: false },
]);
const result = mapStateToProps!(explore, createMockMetricsControlState());
expect(result.columns).toEqual(
expect.arrayContaining([
expect.objectContaining({ column_name: 'filterable_col' }),
expect.objectContaining({ column_name: 'non_filterable_col' }),
]),
);
});
test('columnOptions falls back to datasource columns when queriesResponse is empty', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();

View File

@@ -20,7 +20,7 @@
import { useRef, useEffect, FC, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { logging } from '@apache-superset/core/utils';
import {
SqlLabRootState,

View File

@@ -18,7 +18,7 @@
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { usePrevious } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { Global } from '@emotion/react';

View File

@@ -18,7 +18,7 @@
*/
import { useEffect, useMemo, useRef } from 'react';
import { useStore } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { t } from '@apache-superset/core/translation';
import { getExtensionsRegistry } from '@superset-ui/core';

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { t } from '@apache-superset/core/translation';
import { VizType } from '@superset-ui/core';
import {

View File

@@ -18,7 +18,7 @@
*/
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import URI from 'urijs';
import { pick } from 'lodash';
import { useComponentDidUpdate } from '@superset-ui/core';

View File

@@ -18,7 +18,7 @@
*/
import { useRef } from 'react';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { isObject } from 'lodash';
import rison from 'rison';
import {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { t } from '@apache-superset/core/translation';
import { Dropdown, Button } from '@superset-ui/core/components';
import { Menu } from '@superset-ui/core/components/Menu';

View File

@@ -31,7 +31,7 @@ import { t } from '@apache-superset/core/translation';
import { QueryResponse, QueryState } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import {
queryEditorSetSql,

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import {
useCallback,
useEffect,
@@ -28,7 +29,7 @@ import {
import AutoSizer from 'react-virtualized-auto-sizer';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { useHistory } from 'react-router-dom';
import { pick } from 'lodash';
import {
@@ -378,7 +379,7 @@ const ResultSet = ({
{ rows: rowsCount.toLocaleString() },
),
onConfirm: () => {
window.location.href = getExportCsvUrl(query.id);
window.location.href = sanitizeUrl(getExportCsvUrl(query.id));
},
confirmText: t('OK'),
cancelText: t('Close'),

View File

@@ -16,7 +16,7 @@
* 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';
@@ -43,7 +43,8 @@ import {
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import { useAppDispatch, useAppSelector } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { useAppSelector } from 'src/SqlLab/hooks/useAppSelector';
import rison from 'rison';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
import { addDangerToast } from 'src/components/MessageToasts/actions';
@@ -244,9 +245,9 @@ export const SaveDatasetModal = ({
const createWindow = (url: string) => {
if (openWindow) {
window.open(url, '_blank', 'noreferrer');
window.open(sanitizeUrl(url), '_blank', 'noreferrer');
} else {
window.location.href = url;
window.location.href = sanitizeUrl(url);
}
};
const formDataWithDefaults = {

View File

@@ -18,7 +18,7 @@
*/
import { createRef, useCallback, useMemo } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { nanoid } from 'nanoid';
import Tabs from '@superset-ui/core/components/Tabs';
import { t } from '@apache-superset/core/translation';

View File

@@ -31,7 +31,7 @@ import {
import type { editors } from '@apache-superset/core';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import AutoSizer from 'react-virtualized-auto-sizer';
import { t } from '@apache-superset/core/translation';
import {

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useCallback, useState } from 'react';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { resetState } from 'src/SqlLab/actions/sqlLab';
import {

View File

@@ -20,7 +20,7 @@ import { useMemo, FC } from 'react';
import { bindActionCreators } from 'redux';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { MenuDotsDropdown } from '@superset-ui/core/components';
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
import { t } from '@apache-superset/core/translation';

View File

@@ -18,7 +18,7 @@
*/
import { useEffect, useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { SqlLabRootState } from 'src/SqlLab/types';
import {

View File

@@ -18,7 +18,7 @@
*/
import { useState, useRef, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
import {
ButtonGroup,

View File

@@ -26,7 +26,7 @@ import {
useMemo,
} from 'react';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import AutoSizer from 'react-virtualized-auto-sizer';

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useMemo, useReducer, useCallback } from 'react';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { t } from '@apache-superset/core/translation';
import {
Table,

View File

@@ -18,7 +18,7 @@
*/
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { nanoid } from 'nanoid';
import { t } from '@apache-superset/core/translation';
import { ClientErrorObject, getExtensionsRegistry } from '@superset-ui/core';

View File

@@ -0,0 +1,28 @@
/**
* 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 { useDispatch } from 'react-redux';
import type { AppDispatch } from 'src/views/store';
// In Module Federation deployments where the host shell shares src/views/store
// as a singleton, a version skew between the shell and the SQL Lab chunk can
// leave useAppDispatch undefined at runtime even though TypeScript types it as
// always-present. Keep this hook free of runtime imports from src/views/store:
// store initialization imports SqlLab persistence helpers, so importing store
// values here can create an app-startup circular dependency.
export const useAppDispatch: () => AppDispatch = useDispatch;

View File

@@ -0,0 +1,24 @@
/**
* 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 { useSelector, type TypedUseSelectorHook } from 'react-redux';
import type { RootState } from 'src/views/store';
// Keep this hook free of runtime imports from src/views/store for the same
// Module Federation version-skew case handled by useAppDispatch.
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import rison from 'rison';
import { PureComponent, useCallback, type ReactNode } from 'react';
import { connect, ConnectedProps } from 'react-redux';
@@ -1771,7 +1772,7 @@ class DatasourceEditor extends PureComponent<
renderOpenInSqlLabLink(isError = false) {
return (
<a
href={this.getSQLLabUrl()}
href={sanitizeUrl(this.getSQLLabUrl())}
target="_blank"
rel="noopener noreferrer"
css={theme => css`

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import { PropsWithoutRef, RefAttributes } from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { isUrlExternal, parseUrl } from 'src/utils/urlUtils';
@@ -31,7 +31,7 @@ export const GenericLink = <S,>({
}: PropsWithoutRef<LinkProps<S>> & RefAttributes<HTMLAnchorElement>) => {
if (typeof to === 'string' && isUrlExternal(to)) {
return (
<a data-test="external-link" href={parseUrl(to)} {...rest}>
<a data-test="external-link" href={sanitizeUrl(parseUrl(to))} {...rest}>
{children}
</a>
);

View File

@@ -611,6 +611,21 @@ test('should toggle the edit mode', () => {
expect(logEvent).toHaveBeenCalled();
});
test('should NOT render the Edit dashboard button when embedded', () => {
// Embedded (Embedded SDK) dashboards authenticate with a guest token and so
// have no userId. The Edit button must be hidden even with edit permission,
// since the embedded context cannot handle entering/exiting edit mode.
const embeddedCanEditState = {
dashboardInfo: {
...initialState.dashboardInfo,
dash_edit_perm: true,
userId: undefined,
},
};
setup(embeddedCanEditState);
expect(screen.queryByTestId('edit-dashboard-button')).not.toBeInTheDocument();
});
test('should render the dropdown icon', () => {
setup();
expect(screen.getByRole('img', { name: 'ellipsis' })).toBeInTheDocument();

View File

@@ -749,7 +749,7 @@ const Header = (): JSX.Element => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
{userCanEdit && (
{userCanEdit && !isEmbedded && (
<Button
buttonStyle="secondary"
onClick={handleEnterEditMode}
@@ -776,6 +776,7 @@ const Header = (): JSX.Element => {
handleCtrlZ,
handleEnterEditMode,
hasUnsavedChanges,
isEmbedded,
overwriteDashboard,
redoLength,
undoLength,

View File

@@ -38,10 +38,6 @@ import {
} from '@superset-ui/core';
import withToasts from 'src/components/MessageToasts/withToasts';
import {
OWNER_TEXT_LABEL_PROP,
OWNER_EMAIL_PROP,
} from 'src/features/owners/OwnerSelectLabel';
import { fetchTags, OBJECT_TYPES } from 'src/features/tags/tags';
import {
applyColors,
@@ -65,6 +61,7 @@ import {
CertificationSection,
AdvancedSection,
} from './sections';
import { parseSelectedOwners, type OwnerOption } from './utils';
type PropertiesModalProps = {
dashboardId: number;
@@ -254,17 +251,12 @@ const PropertiesModal = ({
};
const handleOnChangeOwners = (
owners: { value: number; label: string }[],
options: Record<string, unknown>[],
selectedOwners: OwnerOption[],
options: OwnerOption[],
) => {
const parsedOwners: Owners = ensureIsArray(owners).map((o, i) => ({
id: o.value,
full_name:
(options?.[i]?.[OWNER_TEXT_LABEL_PROP] as string) ||
(typeof o.label === 'string' ? o.label : ''),
email: (options?.[i]?.[OWNER_EMAIL_PROP] as string) || '',
}));
setOwners(parsedOwners);
// Use the functional updater so the parse always reads the latest owners
// state rather than the value captured in this render's closure.
setOwners(prev => parseSelectedOwners(selectedOwners, options, prev));
};
const handleOnChangeRoles = (roles: { value: number; label: string }[]) => {

View File

@@ -32,6 +32,7 @@ import {
OWNER_OPTION_FILTER_PROPS,
} from 'src/features/owners/OwnerSelectLabel';
import { useAccessOptions } from '../hooks/useAccessOptions';
import { type OwnerOption } from '../utils';
type Roles = { id: number; name: string }[];
type Owners = {
@@ -47,10 +48,7 @@ interface AccessSectionProps {
owners: Owners;
roles: Roles;
tags: TagType[];
onChangeOwners: (
owners: { value: number; label: string }[],
options: Record<string, unknown>[],
) => void;
onChangeOwners: (owners: OwnerOption[], options: OwnerOption[]) => void;
onChangeRoles: (roles: { value: number; label: string }[]) => void;
onChangeTags: (tags: { label: string; value: number }[]) => void;
onClearTags: () => void;

View File

@@ -0,0 +1,90 @@
/**
* 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 {
OWNER_TEXT_LABEL_PROP,
OWNER_EMAIL_PROP,
} from 'src/features/owners/OwnerSelectLabel';
import { parseSelectedOwners } from './utils';
test('preserves a remaining owner from state when the option cache is partial', () => {
// Owners A(1) and B(2) were loaded from the dashboard, so their full data
// lives only in component state (the controlled `value`), not in the
// AsyncSelect option cache.
const existingOwners = [
{ id: 1, full_name: 'Alice Adams', email: 'alice@example.com' },
{ id: 2, full_name: 'Bob Brown', email: 'bob@example.com' },
];
// The user removes A; onChange fires with only B, and `options` does not
// contain B (it was never searched/loaded).
const selectedOwners = [{ value: 2, label: 'Bob Brown' }];
const options: never[] = [];
// Regression: B must keep its real name/email rather than collapsing into a
// nameless ("undefined undefined") owner.
expect(parseSelectedOwners(selectedOwners, options, existingOwners)).toEqual([
{ id: 2, full_name: 'Bob Brown', email: 'bob@example.com' },
]);
});
test('builds a new owner from the option text label when not already in state', () => {
const options = [
{
value: 3,
// Real labels are OwnerSelectLabel React elements; a number is used here
// simply as a non-string ReactNode for the test.
label: 1,
[OWNER_TEXT_LABEL_PROP]: 'Carol Clark',
[OWNER_EMAIL_PROP]: 'carol@example.com',
},
];
expect(parseSelectedOwners([{ value: 3, label: 1 }], options, [])).toEqual([
{ id: 3, full_name: 'Carol Clark', email: 'carol@example.com' },
]);
});
test('leaves email undefined when option is cached but carries no email', () => {
// The option exists in the cache (so `opt` is defined) but has no
// OWNER_EMAIL_PROP, exercising the `?? undefined` branch directly.
const options = [
{
value: 6,
label: 'Dave Doe',
[OWNER_TEXT_LABEL_PROP]: 'Dave Doe',
// intentionally no OWNER_EMAIL_PROP
},
];
expect(
parseSelectedOwners([{ value: 6, label: 'Dave Doe' }], options, []),
).toEqual([{ id: 6, full_name: 'Dave Doe', email: undefined }]);
});
test('falls back to a string label when the option has no text label', () => {
// No option in the cache => email stays undefined (not an empty string).
expect(
parseSelectedOwners([{ value: 4, label: 'Plain Name' }], [], []),
).toEqual([{ id: 4, full_name: 'Plain Name', email: undefined }]);
});
test('yields an empty name for a non-string label with no text label', () => {
expect(parseSelectedOwners([{ value: 5, label: 1 }], [], [])).toEqual([
{ id: 5, full_name: '', email: undefined },
]);
});

View File

@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type ReactNode } from 'react';
import { ensureIsArray } from '@superset-ui/core';
import {
OWNER_TEXT_LABEL_PROP,
OWNER_EMAIL_PROP,
} from 'src/features/owners/OwnerSelectLabel';
/**
* An owners AsyncSelect option. The `label` is the rendered `OwnerSelectLabel`
* React element (not a string), so the plain-text name is carried separately on
* `OWNER_TEXT_LABEL_PROP` for the options the component constructs.
*/
export type OwnerOption = {
value: number;
label: ReactNode;
[OWNER_TEXT_LABEL_PROP]?: string;
[OWNER_EMAIL_PROP]?: string;
};
export type ParsedOwner = {
id: number;
full_name?: string;
first_name?: string;
last_name?: string;
email?: string;
};
/**
* Resolve the owner objects to persist when the owners AsyncSelect changes.
*
* AsyncSelect only caches the options the user has actually loaded or searched,
* so `options` can be a partial set that is missing owners which only ever
* existed in the controlled `value` prop. We therefore prefer the full owner
* object already in component state (it carries the real name/email from the
* API) and only fall back to the option cache — and finally a string label —
* for genuinely new owners. This prevents a removed owner from collapsing the
* remaining owners into nameless entries.
*/
export function parseSelectedOwners(
selectedOwners: OwnerOption[],
options: OwnerOption[],
existingOwners: ParsedOwner[],
): ParsedOwner[] {
const optionsById = new Map(options.map(opt => [opt.value, opt]));
return ensureIsArray(selectedOwners).map(o => {
const existingOwner = existingOwners.find(ow => ow.id === o.value);
if (existingOwner) {
return existingOwner;
}
const opt = optionsById.get(o.value);
return {
id: o.value,
full_name:
opt?.[OWNER_TEXT_LABEL_PROP] ||
// `label` is a React element unless the option came from a plain-text
// source, so only use it as a name when it is actually a string.
(typeof o.label === 'string' ? o.label : ''),
// Leave email undefined when the option carries no email, rather than
// fabricating an empty string — keeps the optional `email?` type honest.
email: opt?.[OWNER_EMAIL_PROP] ?? undefined,
};
});
}

View File

@@ -26,6 +26,8 @@ import {
} from 'src/hooks/apiResources';
import { SupersetClient } from '@superset-ui/core';
import CrudThemeProvider from 'src/components/CrudThemeProvider';
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
import DashboardPage from './DashboardPage';
const mockTheme = {
@@ -64,6 +66,11 @@ jest.mock('src/dashboard/actions/hydrate', () => ({
hydrateDashboard: jest.fn(() => ({ type: 'MOCK_HYDRATE' })),
}));
jest.mock('src/dashboard/actions/dashboardLayout', () => ({
...jest.requireActual('src/dashboard/actions/dashboardLayout'),
clearDashboardHistory: jest.fn(() => ({ type: 'MOCK_CLEAR_HISTORY' })),
}));
jest.mock('src/dashboard/actions/datasources', () => ({
...jest.requireActual('src/dashboard/actions/datasources'),
setDatasources: jest.fn(() => ({ type: 'MOCK_SET_DATASOURCES' })),
@@ -254,3 +261,31 @@ test('passes null theme when Redux dashboardInfo.theme is explicitly null (theme
expect.anything(),
);
});
test('clears undo history after hydrating the dashboard', async () => {
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="1" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: { id: 1, metadata: {} },
dashboardState: { sliceIds: [] },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
await waitFor(() => {
expect(screen.queryByText('loading')).not.toBeInTheDocument();
});
expect(hydrateDashboard).toHaveBeenCalled();
expect(clearDashboardHistory).toHaveBeenCalled();
const hydrateOrder = (hydrateDashboard as jest.Mock).mock.invocationCallOrder[0];
const clearOrder = (clearDashboardHistory as jest.Mock).mock.invocationCallOrder[0];
expect(clearOrder).toBeGreaterThan(hydrateOrder);
});

View File

@@ -31,6 +31,7 @@ import {
useDashboardDatasets,
} from 'src/hooks/apiResources';
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
import { setDatasources } from 'src/dashboard/actions/datasources';
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
import {
@@ -278,6 +279,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
chartStates: chartStates ?? null,
} as unknown as Parameters<typeof hydrateDashboard>[0]),
);
dispatch(clearDashboardHistory());
// Scroll to anchor element if specified in permalink state
if (anchor) {

View File

@@ -48,6 +48,7 @@ import {
} from './EmbeddedContextProviders';
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
import { validateMessageEvent } from './originValidation';
setupPlugins();
setupCodeOverrides({ embedded: true });
@@ -125,8 +126,6 @@ const EmbeddedApp = () => (
const appMountPoint = document.getElementById('app')!;
const MESSAGE_TYPE = '__embedded_comms__';
function showFailureMessage(message: string) {
appMountPoint.innerHTML = message;
}
@@ -139,17 +138,6 @@ if (!window.parent || window.parent === window) {
);
}
// if the page is embedded in an origin that hasn't
// been authorized by the curator, we forbid access entirely.
// todo: check the referrer on the route serving this page instead
// const ALLOW_ORIGINS = ['http://127.0.0.1:9001', 'http://localhost:9001'];
// const parentOrigin = new URL(document.referrer).origin;
// if (!ALLOW_ORIGINS.includes(parentOrigin)) {
// throw new Error(
// `[superset] iframe parent ${parentOrigin} is not in the list of allowed origins`,
// );
// }
let displayedUnauthorizedToast = false;
let root: Root | null = null;
let started = false;
@@ -225,21 +213,9 @@ function setupGuestClient(guestToken: string) {
});
}
function validateMessageEvent(event: MessageEvent) {
// if (!ALLOW_ORIGINS.includes(event.origin)) {
// throw new Error('Message origin is not in the allowed list');
// }
if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) {
throw new Error(`Message type does not match type used for embedded comms`);
}
}
window.addEventListener('message', function embeddedPageInitializer(event) {
try {
validateMessageEvent(event);
} catch (err) {
log('ignoring message unrelated to embedded comms', err, event);
if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
log('ignoring message unrelated to embedded comms', event);
return;
}

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