Compare commits

...

81 Commits

Author SHA1 Message Date
Evan
4cef74b422 fix: remove unused recalculating state in DropdownContainer
The shouldShowButton approach superseded the recalculating-based
trigger gating, leaving the recalculating state and its setters
unused (TS6133), which broke lint-frontend and validate-frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:25:58 -07:00
Evan Rusackas
55d9e13725 address review: use Button tooltip prop so hint shows on disabled trigger
AntD disabled buttons swallow hover/focus, so the previous
<Tooltip><Button disabled /></Tooltip> wrapper left the
"No applied filters" hint unreachable in the exact state this PR
introduces (button visible, no popover content). The superset-ui Button
component's built-in `tooltip` prop already wraps disabled buttons in a
<span> so the tooltip fires — switch to that.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 20:15:22 -07:00
Evan Rusackas
ec13a2bb7b address review: guard empty popover and disable trigger when no content 2026-06-05 20:15:21 -07:00
Evan Rusackas
e274b45bc6 address review: timeless comment wording 2026-06-05 20:14:37 -07:00
Evan Rusackas
bd1420cfd8 fix(FilterBar): always show 'More filters' button when items exist
This fixes issue #28060 where the "More filters" button would disappear
when filter values were set to their defaults and nothing was overflowing.

The issue was caused by the button only rendering when `popoverContent`
was truthy, which required either `dropdownContent` to be defined OR
`overflowingCount > 0`. When neither condition was met (common when all
filters fit in the container), the button would vanish, causing:
- Inconsistent UI behavior
- Layout shifts as the button appears/disappears
- Poor user experience when resizing the browser window

The fix introduces a `shouldShowButton` flag that ensures the button is
always visible when items exist, regardless of overflow state. The badge
correctly shows 0 when nothing is overflowing, providing clear feedback
to users.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-05 20:14:36 -07:00
Evan Rusackas
b85a2cdab1 fix: ODPS (MaxCompute) data source table preview failed (#38174)
Co-authored-by: zhutong6688 <zhutong66@163.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-05 17:57:44 -07:00
Evan Rusackas
381b99ae84 fix(csv): respect CSV_EXPORT config for decimal separator and delimiter (#38170)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 17:57:21 -07:00
Evan Rusackas
6b0d747939 fix: cache warmup using WebDriver for reliable authentication (#38449)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:36:30 -07:00
Evan Rusackas
151df43d9d fix(docker): prevent static asset 404s by waiting for webpack dev server (#38161)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-05 15:19:50 -07:00
dependabot[bot]
3d7021fdf9 chore(deps): bump hot-shots from 14.3.1 to 15.0.0 in /superset-websocket (#40789)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:48:37 -07:00
dependabot[bot]
2babb48081 chore(deps): bump ioredis from 5.10.1 to 5.11.0 in /superset-websocket (#40734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:06:56 -07:00
dependabot[bot]
4715cfd372 chore(deps-dev): bump eslint-plugin-prettier from 5.5.5 to 5.5.6 in /docs (#40791)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:06:51 -07:00
Evan Rusackas
5a6306983e docs: add social media links to website footer and README (#38108)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-05 14:06:43 -07:00
dependabot[bot]
7f452e4096 chore(deps): bump @ant-design/icons from 6.2.3 to 6.2.5 in /docs (#40792)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:06:30 -07:00
Evan Rusackas
7eaaffde89 ci: cache npm downloads in the translations workflow (#40779)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-05 13:22:20 -07:00
Evan Rusackas
0984839788 ci: required-check anchors for cypress-matrix and playwright-tests (unblock docs-only PRs) (#40780)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-05 13:17:41 -07:00
Rabuma A. Bekele
863e93539a fix(dashboard): clean up JSON formatting and contribution suffix in V… (#40683) 2026-06-05 11:44:03 -07:00
Evan Rusackas
81bc3088e2 fix(dashboard): prevent stale favorite status errors after navigation (#38156)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-05 11:42:54 -07:00
Evan Rusackas
19d01521bf fix(dashboard): replace chartsInScope references at import time (#38171)
Co-authored-by: Rémy Dubois <remy.dubois@komodohealth.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-05 11:42:24 -07:00
Evan Rusackas
1623ceda73 fix(result_set): preserve JSON/JSONB data as objects instead of strings (#38172)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 11:41:40 -07:00
yousoph
e956f82224 fix(dashboard): prevent divider display controls from reverting on second save (#40696)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:36:55 -07:00
dependabot[bot]
2aca35cb68 chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40793)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 11:32:41 -07:00
dependabot[bot]
44777cc110 chore(deps): bump @ant-design/icons from 6.2.3 to 6.2.5 in /superset-frontend (#40794)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 11:32:28 -07:00
dependabot[bot]
20024ce3af chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.10.2 to 0.10.4 in /superset-frontend (#40796)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 11:32:13 -07:00
dependabot[bot]
b069b6caf6 chore(deps-dev): bump terser-webpack-plugin from 5.6.0 to 5.6.1 in /superset-frontend (#40797)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 11:31:59 -07:00
dependabot[bot]
70ee6e21eb chore(deps-dev): bump @babel/core from 7.29.0 to 7.29.7 in /superset-frontend (#40800)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 11:31:44 -07:00
Evan Rusackas
550c80f640 chore(lint): convert ChartRenderer, Chart, DrillByChart to function components (#39459)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-06-05 10:58:44 -07:00
innovark
108e40cbb6 feat(duration-format): replace pretty-ms with native Intl.DurationFormat for localized duration formatting (#39330) 2026-06-05 10:33:17 -07:00
jesperct
8119204857 fix(dashboard): sort Dynamic Group By display values alphabetically (#40220) 2026-06-05 10:32:54 -07:00
dependabot[bot]
645aa3b1df chore(deps-dev): bump eslint-plugin-prettier from 5.5.5 to 5.5.6 in /superset-frontend (#40795)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 10:18:56 -07:00
Evan Rusackas
55bb75efe6 fix(dashboard): prevent filter dropdown button from disappearing during layout recalculations (#38193)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-06-05 10:09:50 -07:00
Richard Fogaca Nienkotter
601f9c2b8c fix(embedded): add guest token to streaming exports (#40712)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-05 13:27:06 -03:00
madhushreeag
fa42b13eb8 fix(dataset): preserve numeric column types when pydruid infers STRING from first-row value (#40677)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-05 09:25:57 -07:00
Amin Ghadersohi
aa4092ba68 fix(mcp): add select_columns lean defaults to get_dashboard_info, get_chart_info, get_dataset_info (#40473)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Richard Fogaça <richardfogaca@gmail.com>
2026-06-05 11:10:13 -03:00
dependabot[bot]
45a616439b chore(deps): update dayjs requirement from ^1.11.20 to ^1.11.21 in /superset-frontend/packages/superset-ui-core (#40736)
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 Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:59:17 -07:00
dependabot[bot]
98c096df05 chore(deps): bump @babel/runtime from 7.29.2 to 7.29.7 in /superset-frontend (#40753)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 20:59:08 -07:00
Elizabeth Thompson
42367afb25 fix(reports): add per-tile animation wait to prevent partial ECharts renders in tiled screenshots (#40694)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:43:34 -07:00
Evan Rusackas
875673f670 fix(asyncEvent): use Map for job listener/retry registries (#40747)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 14:16:44 -07:00
Evan Rusackas
79c74af2e9 ci: cache npm downloads in frontend-heavy workflows (#40744)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 13:34:35 -07:00
Vitor Avila
7406098708 fix(dashboard-filter): Consider dashboard filters to charts not declared in the dashboard position (#40774) 2026-06-04 16:43:38 -03:00
dependabot[bot]
ccce0cab18 chore(deps): bump content-disposition from 2.0.0 to 2.0.1 in /superset-frontend (#40750)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:32:09 -07:00
dependabot[bot]
94c1a1b1f2 chore(deps-dev): bump @babel/runtime-corejs3 from 7.29.2 to 7.29.7 in /superset-frontend (#40751)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:31:59 -07:00
dependabot[bot]
04939c94cc chore(deps-dev): bump @babel/node from 7.29.0 to 7.29.7 in /superset-frontend (#40752)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:31:49 -07:00
dependabot[bot]
937eff6d52 chore(deps-dev): bump oxlint from 1.66.0 to 1.67.0 in /superset-frontend (#40755)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:31:23 -07:00
dependabot[bot]
f5f4a41598 chore(deps-dev): bump @babel/register from 7.29.3 to 7.29.7 in /superset-frontend (#40757)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:30:55 -07:00
Evan Rusackas
639866625d fix(echarts): Show full labels in bar chart tooltips (#34759)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-04 12:29:48 -07:00
Evan Rusackas
7d323dc0ae fix(filters): Enable decimal values in Range filter slider (#34742)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 12:29:33 -07:00
Evan Rusackas
0d1b702ce8 feat(extensions): static supply-chain controls — denylist + version policy (#40668)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 12:29:03 -07:00
dependabot[bot]
ddeec68c88 chore(deps): bump dompurify from 3.4.5 to 3.4.7 in /superset-frontend/plugins/legacy-preset-chart-nvd3 (#40735)
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 Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:28:13 -07:00
dependabot[bot]
0ad09d5cd0 chore(deps): bump dompurify from 3.4.8 to 3.4.7 in /superset-frontend/packages/superset-ui-core (#40737)
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 Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:27:26 -07:00
dependabot[bot]
6662529306 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40739)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:27:18 -07:00
dependabot[bot]
09cd2c26cd chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40740)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:27:05 -07:00
dependabot[bot]
cbd731e661 chore(deps-dev): bump webpack from 5.107.1 to 5.107.2 in /superset-frontend (#40741)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:26:54 -07:00
dependabot[bot]
3f94c9db2d chore(deps): bump query-string from 9.3.1 to 9.4.0 in /superset-frontend (#40742)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:26:46 -07:00
Evan Rusackas
80a3df3550 ci: run full Python-version matrix on push, current-only on PRs (#40722)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 21:17:29 +02:00
Evan Rusackas
6f97d9817e fix(database): preserve engine_information when creating database connection (#38107)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-04 12:04:59 -07:00
Amin Ghadersohi
7d69f76127 fix(mcp): API key authentication for MCP — transport, validation, and RBAC (#39604) 2026-06-04 15:04:43 -04:00
Evan Rusackas
9a31362fa5 fix(reports): stamp email subject date at send time, not import time (#40693)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:03:28 -07:00
Joe Li
cd5bdf11ac fix(playwright): de-flake list-view delete and bulk-export specs (#39980)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 11:41:36 -07:00
Evan Rusackas
75d94ff466 fix(SafeMarkdown): block script-executing link protocols regardless of EscapeMarkdownHtml (#40622)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 11:13:31 -07:00
Evan Rusackas
c505c70c52 fix(databases): do not render existing encrypted field value in edit mode (#40628)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 10:15:01 -07:00
Evan Rusackas
23d18743bd fix(deck.gl): strip all JS-executed form_data keys when JavaScript controls are disabled (#40602)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 10:14:33 -07:00
Evan Rusackas
ddb09f468d fix(plugin-chart-ag-grid-table): enforce numeric bounds for range (BETWEEN) filters (#40607)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Shaitan <105581038+sha174n@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-04 10:14:21 -07:00
Evan Rusackas
8dcc7e7eec ci: stable required-check anchors for skippable matrix test jobs (#40772)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:50:06 -07:00
Evan Rusackas
ff5e43c8a0 ci: add timeout-minutes to compute-heavy workflow jobs (#40743)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:47:55 -07:00
Evan Rusackas
bdb081329f feat(websocket): validate WebSocket upgrade Origin against an allowlist (#40625)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:43:16 -07:00
Evan Rusackas
aa547da960 fix: remove registration_hash in the registrations API (#40643)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:43:03 -07:00
Evan Rusackas
966c243db6 ci: drop removed Cypress shards from required status checks (#40770)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 18:23:47 +02:00
Evan Rusackas
696705794b ci: gate docker image builds at the job level (#40723)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 15:39:01 -07:00
Shaitan
41572dbf9d fix(chart): restrict owner lookup to users with write access (#39304)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:00:31 +01:00
Evan Rusackas
5ba60d51fd ci: gate CodeQL analysis at the job level for docs-only PRs (#40724) 2026-06-03 23:49:59 +02: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
232 changed files with 10940 additions and 3299 deletions

View File

@@ -77,23 +77,17 @@ github:
# combination here.
contexts:
- lint-check
- cypress-matrix (0, chrome)
- cypress-matrix (1, chrome)
- cypress-matrix (2, chrome)
- cypress-matrix (3, chrome)
- cypress-matrix (4, chrome)
- cypress-matrix (5, chrome)
- cypress-matrix-required
- dependency-review
- frontend-build
- playwright-tests (chromium)
- playwright-tests-required
- pre-commit (current)
- pre-commit (previous)
- test-mysql
- test-postgres (current)
- test-postgres-required
- test-postgres-hive
- test-postgres-presto
- test-sqlite
- unit-tests (current)
- unit-tests-required
required_pull_request_reviews:
dismiss_stale_reviews: false

View File

@@ -15,9 +15,35 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
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.0.2
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
analyze:
name: Analyze
needs: changes
# Skip on PRs that touch neither code group (e.g. docs-only) so the
# analysis runners don't spin up. push/schedule runs always proceed:
# the change-detector returns "all changed" for non-PR events.
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
actions: read
contents: read
@@ -31,16 +57,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
@@ -54,7 +74,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -19,8 +19,30 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
frontend: ${{ steps.check.outputs.frontend }}
docker: ${{ steps.check.outputs.docker }}
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 }}
setup_matrix:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
matrix_config: ${{ steps.set_matrix.outputs.matrix_config }}
steps:
@@ -32,8 +54,13 @@ jobs:
docker-build:
name: docker-build
needs: setup_matrix
needs: [setup_matrix, changes]
if: >-
needs.changes.outputs.python == 'true' ||
needs.changes.outputs.frontend == 'true' ||
needs.changes.outputs.docker == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
matrix:
build_preset: ${{fromJson(needs.setup_matrix.outputs.matrix_config)}}
@@ -50,14 +77,7 @@ jobs:
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Docker Environment
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
@@ -65,11 +85,9 @@ jobs:
build: "true"
- name: Setup supersetbot
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
uses: ./.github/actions/setup-supersetbot/
- name: Build Docker Image
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -95,7 +113,7 @@ jobs:
# in the context of push (using multi-platform build), we need to pull the image locally
- name: Docker pull
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
if: github.event_name == 'push'
run: |
for i in 1 2 3; do
docker pull $IMAGE_TAG && break
@@ -103,7 +121,6 @@ jobs:
done
- name: Print docker stats
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
run: |
echo "SHA: ${{ github.sha }}"
echo "IMAGE: $IMAGE_TAG"
@@ -111,7 +128,7 @@ jobs:
docker history $IMAGE_TAG
- name: docker-compose sanity check
if: (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'dev'
if: matrix.build_preset == 'dev'
shell: bash
env:
BUILD_PRESET: ${{ matrix.build_preset }}
@@ -124,20 +141,16 @@ jobs:
docker-compose-image-tag:
# Run this job only on pushes to master (not for PRs)
# goal is to check that building the latest image works, not required for all PR pushes
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: changes
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.changes.outputs.docker == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
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 }}
- name: Setup Docker Environment
if: steps.check.outputs.docker
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
@@ -145,7 +158,6 @@ jobs:
build: "false"
install-docker-compose: "true"
- name: docker-compose sanity check
if: steps.check.outputs.docker
shell: bash
run: |
docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init

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

@@ -19,9 +19,13 @@ concurrency:
jobs:
pre-commit:
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
matrix:
python-version: ["current", "previous", "next"]
# Run the full version spread on push (master/release) and nightly,
# but only the current version on PRs — lint/format/type results
# rarely differ across patch versions, so 3x per PR is wasteful.
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -45,6 +49,8 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'superset-frontend/package-lock.json'
- name: Install Frontend Dependencies
run: |

View File

@@ -29,6 +29,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -51,6 +52,7 @@ jobs:
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
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
@@ -61,9 +63,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('[""]') }}
# 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
@@ -124,6 +131,8 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-frontend/.nvmrc'
cache: 'npm'
cache-dependency-path: 'superset-frontend/package-lock.json'
- name: Install npm dependencies
uses: ./.github/actions/cached-dependencies
with:
@@ -141,7 +150,7 @@ jobs:
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:
@@ -165,6 +174,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
@@ -231,6 +241,8 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-frontend/.nvmrc'
cache: 'npm'
cache-dependency-path: 'superset-frontend/package-lock.json'
- name: Install npm dependencies
uses: ./.github/actions/cached-dependencies
with:
@@ -269,3 +281,63 @@ 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 }}
# Stable required-status-check anchors. cypress-matrix and playwright-tests
# are matrix jobs gated on change detection (python || frontend). On a PR
# that touches neither — e.g. a docs-only PR — they are skipped at the job
# level, which happens before matrix expansion, so the per-combination
# contexts (`cypress-matrix (0, chrome)`, `playwright-tests (chromium)`) are
# never produced and branch protection waits on them forever. These
# always-running jobs report a single stable context that passes when the
# underlying matrix job succeeded or was skipped, and fails only on a real
# failure. Require these in .asf.yaml instead of the matrix-expanded names.
#
# A matrix job reads as "skipped" in two distinct cases, and only the first
# is a legitimate pass: (a) change detection succeeded and gated the job off
# (docs-only PR); (b) the `changes` job itself failed or was cancelled, in
# which case GHA skips its dependents too. Accepting (b) would let a broken
# change-detector report a false green, so each anchor first requires
# `changes` to have succeeded before honouring a skip.
cypress-matrix-required:
needs: [changes, cypress-matrix]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
steps:
- name: Check cypress-matrix result
env:
CHANGES: ${{ needs.changes.result }}
RESULT: ${{ needs.cypress-matrix.result }}
run: |
if [ "$CHANGES" != "success" ]; then
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
exit 1
fi
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
echo "cypress-matrix did not pass (result: $RESULT)"
exit 1
fi
echo "cypress-matrix result: $RESULT (changes: $CHANGES)"
playwright-tests-required:
needs: [changes, playwright-tests]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
steps:
- name: Check playwright-tests result
env:
CHANGES: ${{ needs.changes.result }}
RESULT: ${{ needs.playwright-tests.result }}
run: |
if [ "$CHANGES" != "success" ]; then
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
exit 1
fi
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
echo "playwright-tests did not pass (result: $RESULT)"
exit 1
fi
echo "playwright-tests result: $RESULT (changes: $CHANGES)"

View File

@@ -20,9 +20,12 @@ concurrency:
jobs:
test-superset-extensions-cli-package:
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
matrix:
python-version: ["previous", "current", "next"]
# Full version spread on push (master/release) + nightly; current only
# on PRs to cut runner cost (cross-version breaks are caught at merge).
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
defaults:
run:
working-directory: superset-extensions-cli

View File

@@ -22,6 +22,7 @@ permissions:
jobs:
frontend-build:
runs-on: ubuntu-24.04
timeout-minutes: 30
outputs:
should-run: ${{ steps.check.outputs.frontend }}
steps:
@@ -74,6 +75,7 @@ jobs:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
fail-fast: false
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -103,6 +105,7 @@ jobs:
needs: [sharded-jest-tests]
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
id-token: write
steps:
@@ -144,6 +147,7 @@ jobs:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -168,6 +172,7 @@ jobs:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -187,6 +192,7 @@ jobs:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8

View File

@@ -25,6 +25,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -48,6 +49,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 30
continue-on-error: true
permissions:
contents: read
@@ -115,6 +117,8 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-frontend/.nvmrc'
cache: 'npm'
cache-dependency-path: 'superset-frontend/package-lock.json'
- name: Install npm dependencies
uses: ./.github/actions/cached-dependencies
with:

View File

@@ -16,6 +16,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -36,6 +37,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:
@@ -49,9 +51,6 @@ jobs:
image: mysql:8.0
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
credentials:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
MYSQL_ROOT_PASSWORD: root
ports:
@@ -63,9 +62,6 @@ jobs:
--health-retries=5
redis:
image: redis:7-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
options: --entrypoint redis-server
ports:
- 16379:6379
@@ -127,11 +123,14 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
strategy:
matrix:
python-version: ["current", "previous", "next"]
# Full version spread on push (master/release) + nightly; current only
# on PRs to cut runner cost (cross-version breaks are caught at merge).
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -140,9 +139,6 @@ jobs:
services:
postgres:
image: postgres:17-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -152,9 +148,6 @@ jobs:
- 15432:5432
redis:
image: redis:7-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 16379:6379
steps:
@@ -191,6 +184,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:
@@ -204,9 +198,6 @@ jobs:
services:
redis:
image: redis:7-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 16379:6379
steps:
@@ -237,3 +228,25 @@ jobs:
verbose: true
use_oidc: true
slug: apache/superset
# Stable required-status-check anchor for the matrix-based test-postgres job.
# It is gated on change detection, so on non-Python PRs it is skipped and
# never produces its `test-postgres (current)` context (a job-level skip
# happens before matrix expansion). This always-running job reports a single
# context branch protection can require: it passes when test-postgres
# succeeded or was skipped, and fails only on a real failure.
test-postgres-required:
needs: [changes, test-postgres]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check test-postgres result
env:
RESULT: ${{ needs.test-postgres.result }}
run: |
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
echo "test-postgres did not pass (result: $RESULT)"
exit 1
fi
echo "test-postgres result: $RESULT"

View File

@@ -17,6 +17,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -37,6 +38,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:
@@ -99,6 +101,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:

View File

@@ -17,6 +17,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -37,11 +38,14 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
id-token: write
strategy:
matrix:
python-version: ["previous", "current", "next"]
# Full version spread on push (master/release) + nightly; current only
# on PRs to cut runner cost (cross-version breaks are caught at merge).
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
steps:
@@ -74,3 +78,25 @@ jobs:
verbose: true
use_oidc: true
slug: apache/superset
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
# change detection, so on non-Python PRs it is skipped and never produces its
# `unit-tests (current)` context (a job-level skip happens before matrix
# expansion). This always-running job reports a single context that branch
# protection can require: it passes when unit-tests succeeded or was skipped,
# and fails only on a real failure.
unit-tests-required:
needs: [changes, unit-tests]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check unit-tests result
env:
RESULT: ${{ needs.unit-tests.result }}
run: |
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
echo "unit-tests did not pass (result: $RESULT)"
exit 1
fi
echo "unit-tests result: $RESULT"

View File

@@ -41,6 +41,8 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-frontend/.nvmrc'
cache: 'npm'
cache-dependency-path: 'superset-frontend/package-lock.json'
- name: Install dependencies
if: steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies

View File

@@ -22,6 +22,7 @@ concurrency:
jobs:
app-checks:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

View File

@@ -189,6 +189,11 @@ Try out Superset's [quickstart](https://superset.apache.org/docs/quickstart/) gu
- [Join our community's Slack](http://bit.ly/join-superset-slack)
and please read our [Slack Community Guidelines](https://github.com/apache/superset/blob/master/CODE_OF_CONDUCT.md#slack-community-guidelines)
- [Join our dev@superset.apache.org Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org). To join, simply send an email to [dev-subscribe@superset.apache.org](mailto:dev-subscribe@superset.apache.org)
- Follow us on social media:
[X](https://x.com/apachesuperset) |
[LinkedIn](https://www.linkedin.com/company/apache-superset) |
[Bluesky](https://bsky.app/profile/apachesuperset.bsky.social) |
[Reddit](https://reddit.com/r/apache-superset)
- If you want to help troubleshoot GitHub Issues involving the numerous database drivers that Superset supports, please consider adding your name and the databases you have access to on the [Superset Database Familiarity Rolodex](https://docs.google.com/spreadsheets/d/1U1qxiLvOX0kBTUGME1AHHi6Ywel6ECF8xk_Qy-V9R8c/edit#gid=0)
- Join Superset's Town Hall and [Operational Model](https://preset.io/blog/the-superset-operational-model-wants-you/) recurring meetings. Meeting info is available on the [Superset Community Calendar](https://superset.apache.org/community)

View File

@@ -24,6 +24,16 @@ assists people when migrating to a new version.
## Next
### Duration formatter precision
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
To preserve sub-second precision in custom duration formatters, enable `formatSubMilliseconds`.
### Cache warmup authenticates via SUPERSET_CACHE_WARMUP_USER
The `cache-warmup` Celery task now drives a real WebDriver session for reliable authentication and reads the user to authenticate as from the new `SUPERSET_CACHE_WARMUP_USER` config option. It no longer consults `CACHE_WARMUP_EXECUTORS` for the warmup path. `SUPERSET_CACHE_WARMUP_USER` defaults to `None`, so the task fails fast with a clear message until you set it. Operators who previously relied on `CACHE_WARMUP_EXECUTORS` for cache warmup must set `SUPERSET_CACHE_WARMUP_USER` to a dedicated least-privilege user with access to the dashboards they want warmed up before the next warmup run.
### 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.
@@ -40,6 +50,19 @@ Importing a dataset now validates the `catalog` field against the target databas
If you relied on importing datasets with a non-default catalog, enable "Allow changing catalogs" on the target connection, or set the dataset's catalog to the connection's default before importing.
### Extension supply-chain controls (denylist + version policy)
Two opt-in static gates control which extensions are allowed to load:
- `EXTENSION_DENYLIST` refuses extensions matching an id (every version) or `id@version` (a single version), e.g. `["compromised-extension", "other-ext@1.2.3"]`.
- `EXTENSION_VERSION_POLICY` enforces a minimum version per extension id, e.g. `{"acme.widget": "1.2.0"}` (PEP 440 comparison); a release below the minimum is refused.
Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENSIONS` and `EXTENSIONS_PATH` load paths.
### Dynamic Group By respects the sort toggle for display values
The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (AZ), descending (ZA), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of AZ; open the customization and enable the toggle to restore alphabetical ordering.
### Granular Export Controls
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:

View File

@@ -61,6 +61,31 @@ services:
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./docker/nginx/templates:/etc/nginx/templates:ro
# Wait for the webpack dev server's manifest.json to be served before
# starting nginx. This prevents 404s on static assets at startup. The
# probe targets host.docker.internal so it works regardless of whether
# the dev server runs in the superset-node container
# (BUILD_SUPERSET_FRONTEND_IN_DOCKER=true, the default) or directly on
# the host (BUILD_SUPERSET_FRONTEND_IN_DOCKER=false).
command:
- /bin/bash
- -c
- |
url="http://host.docker.internal:9000/static/assets/manifest.json"
max_attempts=150 # ~5 minutes at 2s intervals
echo "Waiting for webpack dev server at $url..."
attempt=0
until curl -sf --max-time 5 -o /dev/null "$url"; do
attempt=$((attempt + 1))
if [ "$attempt" -ge "$max_attempts" ]; then
echo "ERROR: webpack dev server did not serve $url after $max_attempts attempts (~5 minutes)." >&2
echo "Is the dev server running? With BUILD_SUPERSET_FRONTEND_IN_DOCKER=false you must start it on the host (e.g. 'npm run dev' in superset-frontend)." >&2
exit 1
fi
sleep 2
done
echo "Webpack dev server is ready; starting nginx."
exec nginx -g 'daemon off;'
redis:
image: redis:7

View File

@@ -86,6 +86,39 @@ instead requires a cachelib object.
See [Async Queries via Celery](/admin-docs/configuration/async-queries-celery) for details.
## Celery beat
Superset has a Celery task that will periodically warm up the cache based on different strategies.
To use it, add the following to your `superset_config.py`:
```python
from celery.schedules import crontab
from superset.config import CeleryConfig
# User that will be used to authenticate and render dashboards for cache warmup
SUPERSET_CACHE_WARMUP_USER = "user_with_permission_to_dashboards"
# Extend the default CeleryConfig to add cache warmup schedule
class CustomCeleryConfig(CeleryConfig):
beat_schedule = {
**CeleryConfig.beat_schedule,
'cache-warmup-hourly': {
'task': 'cache-warmup',
'schedule': crontab(minute=0, hour='*'), # hourly
'kwargs': {
'strategy_name': 'top_n_dashboards',
'top_n': 5,
'since': '7 days ago',
},
},
}
CELERY_CONFIG = CustomCeleryConfig
```
This will cache the top 5 most popular dashboards every hour. For other
strategies, check the `superset/tasks/cache.py` file.
## Caching Thumbnails
This is an optional feature that can be turned on by activating its [feature flag](/admin-docs/configuration/configuring-superset#feature-flags) on config:

View File

@@ -917,6 +917,23 @@ const config: Config = {
footer: {
links: [],
copyright: `
<div class="footer__social-links">
<a href="https://bit.ly/join-superset-slack" target="_blank" rel="noopener noreferrer" title="Join us on Slack" aria-label="Slack">
<img src="/img/community/slack-symbol.svg" alt="Slack" />
</a>
<a href="https://x.com/apachesuperset" target="_blank" rel="noopener noreferrer" title="Follow us on X" aria-label="X">
<img src="/img/community/x-symbol.svg" alt="X" />
</a>
<a href="https://www.linkedin.com/company/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on LinkedIn" aria-label="LinkedIn">
<img src="/img/community/linkedin-symbol.svg" alt="LinkedIn" />
</a>
<a href="https://bsky.app/profile/apachesuperset.bsky.social" target="_blank" rel="noopener noreferrer" title="Follow us on Bluesky" aria-label="Bluesky">
<img src="/img/community/bluesky-symbol.svg" alt="Bluesky" />
</a>
<a href="https://reddit.com/r/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on Reddit" aria-label="Reddit">
<img src="/img/community/reddit-symbol.svg" alt="Reddit" />
</a>
</div>
<div class="footer__ci-services">
<span>CI powered by</span>
<a href="https://www.netlify.com/" target="_blank" rel="nofollow noopener noreferrer"><img src="/img/netlify.png" alt="Netlify" title="Netlify - Deploy Previews" /></a>

View File

@@ -1,6 +1,6 @@
{
"copyright": {
"message": "\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a>&nbsp;|&nbsp;\n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a>&nbsp;|&nbsp;\n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a>&nbsp;|&nbsp;\n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a>&nbsp;|&nbsp;\n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a>&nbsp;|&nbsp;\n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
"message": "\n <div class=\"footer__social-links\">\n <a href=\"https://bit.ly/join-superset-slack\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Join us on Slack\" aria-label=\"Slack\">\n <img src=\"/img/community/slack-symbol.svg\" alt=\"Slack\" />\n </a>\n <a href=\"https://x.com/apachesuperset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on X\" aria-label=\"X\">\n <img src=\"/img/community/x-symbol.svg\" alt=\"X\" />\n </a>\n <a href=\"https://www.linkedin.com/company/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on LinkedIn\" aria-label=\"LinkedIn\">\n <img src=\"/img/community/linkedin-symbol.svg\" alt=\"LinkedIn\" />\n </a>\n <a href=\"https://bsky.app/profile/apachesuperset.bsky.social\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Bluesky\" aria-label=\"Bluesky\">\n <img src=\"/img/community/bluesky-symbol.svg\" alt=\"Bluesky\" />\n </a>\n <a href=\"https://reddit.com/r/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Reddit\" aria-label=\"Reddit\">\n <img src=\"/img/community/reddit-symbol.svg\" alt=\"Reddit\" />\n </a>\n </div>\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/admin-docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a>&nbsp;|&nbsp;\n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a>&nbsp;|&nbsp;\n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a>&nbsp;|&nbsp;\n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a>&nbsp;|&nbsp;\n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a>&nbsp;|&nbsp;\n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
"description": "The footer copyright"
}
}

View File

@@ -43,7 +43,7 @@
"version:remove:components": "node scripts/manage-versions.mjs remove components"
},
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@ant-design/icons": "^6.2.5",
"@docusaurus/core": "^3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/plugin-client-redirects": "^3.10.1",
@@ -104,7 +104,7 @@
"@typescript-eslint/parser": "^8.60.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.6.0",
"prettier": "^3.8.3",

View File

@@ -260,10 +260,39 @@ a > span > svg {
.footer {
position: relative;
padding-top: 90px;
padding-top: 130px;
font-size: 15px;
}
.footer__social-links {
background-color: #173036;
position: absolute;
top: 52px;
left: 0;
width: 100%;
padding: 10px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
}
.footer__social-links a {
display: inline-flex;
align-items: center;
transition: opacity 0.2s, transform 0.2s;
}
.footer__social-links a:hover {
opacity: 0.8;
transform: scale(1.1);
}
.footer__social-links img {
height: 24px;
width: 24px;
}
.footer__ci-services {
background-color: #0d3e49;
color: #e1e1e1;
@@ -309,6 +338,21 @@ a > span > svg {
}
@media only screen and (max-width: 996px) {
.footer {
padding-top: 120px;
}
.footer__social-links {
top: 44px;
gap: 20px;
padding: 8px 16px;
}
.footer__social-links img {
height: 20px;
width: 20px;
}
.footer__ci-services {
gap: 12px;
padding: 10px 16px;

View File

@@ -0,0 +1,21 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#FF4500">
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12c-.688 0-1.25.561-1.25 1.25 0 .687.562 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,21 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#4A154B">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.124 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.166 0a2.528 2.528 0 0 1 2.521 2.522v6.312zm-2.521 10.124a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.166 24a2.528 2.528 0 0 1-2.521-2.52v-2.522h2.521zm0-1.271a2.528 2.528 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.312A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.52 2.521h-6.313z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -212,14 +212,14 @@
resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz"
integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==
"@ant-design/icons@^6.2.3":
version "6.2.3"
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.3.tgz#66e1c7fdea009b9c3fab6964062bedc76f308ad8"
integrity sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==
"@ant-design/icons@^6.2.3", "@ant-design/icons@^6.2.5":
version "6.2.5"
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.5.tgz#31c142aa6ce5eaf99598aaead222f4c459693512"
integrity sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/icons-svg" "^4.4.2"
"@rc-component/util" "^1.10.1"
"@rc-component/util" "^1.11.0"
clsx "^2.1.1"
"@ant-design/react-slick@~2.0.0":
@@ -3021,10 +3021,10 @@
os-homedir "^1.0.1"
regexpu-core "^4.5.4"
"@pkgr/core@^0.2.9":
version "0.2.9"
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz"
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
"@pkgr/core@^0.3.6":
version "0.3.6"
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.3.6.tgz#3569708bd4be4d8870ba32bf1c456dac81600d97"
integrity sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==
"@pnpm/config.env-replace@^1.1.0":
version "1.1.0"
@@ -7522,13 +7522,13 @@ eslint-config-prettier@^10.1.8:
resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz"
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
eslint-plugin-prettier@^5.5.5:
version "5.5.5"
resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz"
integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==
eslint-plugin-prettier@^5.5.6:
version "5.5.6"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564"
integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==
dependencies:
prettier-linter-helpers "^1.0.1"
synckit "^0.11.12"
synckit "^0.11.13"
eslint-plugin-react@^7.37.5:
version "7.37.5"
@@ -14096,12 +14096,12 @@ swc-loader@^0.2.6, swc-loader@^0.2.7:
dependencies:
"@swc/counter" "^0.1.3"
synckit@^0.11.12:
version "0.11.12"
resolved "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz"
integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==
synckit@^0.11.13:
version "0.11.13"
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a"
integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==
dependencies:
"@pkgr/core" "^0.2.9"
"@pkgr/core" "^0.3.6"
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
version "2.3.3"

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

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
@@ -50,8 +67,8 @@ Typical CI workflow
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 restore lost
translations.
PR worktree run still allows committed .po updates to resolve the fuzzies (and
thus clear the regression) before merging.
"""
import argparse
@@ -71,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
@@ -90,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,
@@ -120,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"
@@ -169,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(
@@ -199,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

@@ -69,7 +69,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
],
preset: 'ts-jest',
transform: {

View File

@@ -86,10 +86,10 @@
"antd": "^5.26.0",
"chrono-node": "^2.9.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.0",
"content-disposition": "^2.0.1",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.20",
"dayjs": "^1.11.21",
"dom-to-image-more": "^3.7.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
@@ -118,8 +118,7 @@
"mustache": "^4.2.0",
"nanoid": "^5.1.11",
"ol": "^10.9.0",
"pretty-ms": "^9.3.0",
"query-string": "9.3.1",
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react-arborist": "^3.8.0",
@@ -163,9 +162,9 @@
"devDependencies": {
"@babel/cli": "^7.29.7",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.29.0",
"@babel/core": "^7.29.7",
"@babel/eslint-parser": "^7.29.7",
"@babel/node": "^7.29.0",
"@babel/node": "^7.29.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
@@ -173,12 +172,13 @@
"@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/register": "^7.29.7",
"@babel/runtime": "^7.29.7",
"@babel/runtime-corejs3": "^7.29.7",
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.3",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.60.0",
@@ -248,9 +248,9 @@
"eslint-plugin-jest-dom": "^5.5.0",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -270,7 +270,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.66.0",
"oxlint": "^1.67.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
@@ -284,7 +284,7 @@
"storybook": "8.6.18",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.0",
"terser-webpack-plugin": "^5.6.1",
"ts-jest": "^29.4.11",
"tscw-config": "^1.1.2",
"tsx": "^4.22.3",
@@ -292,7 +292,7 @@
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.10",
"webpack": "^5.107.1",
"webpack": "^5.107.2",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",
@@ -593,21 +593,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -966,27 +966,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/node": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/node/-/node-7.29.0.tgz",
"integrity": "sha512-9UeU8F3rx2lOZXneEW2HTnTYdA8+fXP0kr54tk7d0fPomWNlZ6WJ2H9lunr5dSvr8FNY0CDnop3Km6jZ5NAUsQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/node/-/node-7.29.7.tgz",
"integrity": "sha512-nfdPXz8/mD3/t+1nE1DKwGR14Ccjt5xeF7u3g7sqWnLi4yR6n+9Z0kThIROF8SRM07ZKpEtiWSKpWKxsMiJeew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/register": "^7.28.6",
"@babel/register": "^7.29.7",
"commander": "^6.2.0",
"core-js": "^3.48.0",
"node-environment-flags": "^1.0.5",
@@ -2576,9 +2576,9 @@
}
},
"node_modules/@babel/register": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.3.tgz",
"integrity": "sha512-F6C1KpIdoImKQfsD6HSxZ+mS4YY/2Q+JsqrmTC5ApVkTR2rG+nnbpjhWwzA5bDNu8mJjB3AryqDaWFLd4gCbJQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz",
"integrity": "sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2596,18 +2596,18 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime-corejs3": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz",
"integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz",
"integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3954,6 +3954,53 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@formatjs/bigdecimal": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.0.tgz",
"integrity": "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.2.0.tgz",
"integrity": "sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/bigdecimal": "0.2.0",
"@formatjs/fast-memoize": "3.1.1",
"@formatjs/intl-localematcher": "0.8.2"
}
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz",
"integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/intl-durationformat": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.3.tgz",
"integrity": "sha512-xRS3GaOlsQLwz0n56SvaddwEnl2NLPKBvYg2M32ak/27dodmVxFJz3j7Nqj7EwKyHTu3f/e+BeoKPrIDUSXTuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "3.2.0",
"@formatjs/intl-localematcher": "0.8.2"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz",
"integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.1"
}
},
"node_modules/@gar/promise-retry": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz",
@@ -8653,9 +8700,9 @@
"license": "MIT"
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.66.0.tgz",
"integrity": "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.67.0.tgz",
"integrity": "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==",
"cpu": [
"arm"
],
@@ -8670,9 +8717,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.66.0.tgz",
"integrity": "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.67.0.tgz",
"integrity": "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==",
"cpu": [
"arm64"
],
@@ -8687,9 +8734,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.66.0.tgz",
"integrity": "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.67.0.tgz",
"integrity": "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==",
"cpu": [
"arm64"
],
@@ -8704,9 +8751,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.66.0.tgz",
"integrity": "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.67.0.tgz",
"integrity": "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==",
"cpu": [
"x64"
],
@@ -8721,9 +8768,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.66.0.tgz",
"integrity": "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.67.0.tgz",
"integrity": "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==",
"cpu": [
"x64"
],
@@ -8738,9 +8785,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.66.0.tgz",
"integrity": "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.67.0.tgz",
"integrity": "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==",
"cpu": [
"arm"
],
@@ -8755,9 +8802,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.66.0.tgz",
"integrity": "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.67.0.tgz",
"integrity": "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==",
"cpu": [
"arm"
],
@@ -8772,9 +8819,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.66.0.tgz",
"integrity": "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.67.0.tgz",
"integrity": "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==",
"cpu": [
"arm64"
],
@@ -8789,9 +8836,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.66.0.tgz",
"integrity": "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.67.0.tgz",
"integrity": "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==",
"cpu": [
"arm64"
],
@@ -8806,9 +8853,9 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.66.0.tgz",
"integrity": "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.67.0.tgz",
"integrity": "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==",
"cpu": [
"ppc64"
],
@@ -8823,9 +8870,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.66.0.tgz",
"integrity": "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.67.0.tgz",
"integrity": "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==",
"cpu": [
"riscv64"
],
@@ -8840,9 +8887,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.66.0.tgz",
"integrity": "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.67.0.tgz",
"integrity": "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==",
"cpu": [
"riscv64"
],
@@ -8857,9 +8904,9 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.66.0.tgz",
"integrity": "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.67.0.tgz",
"integrity": "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==",
"cpu": [
"s390x"
],
@@ -8874,9 +8921,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.66.0.tgz",
"integrity": "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.67.0.tgz",
"integrity": "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==",
"cpu": [
"x64"
],
@@ -8891,9 +8938,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.66.0.tgz",
"integrity": "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.67.0.tgz",
"integrity": "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==",
"cpu": [
"x64"
],
@@ -8908,9 +8955,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.66.0.tgz",
"integrity": "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.67.0.tgz",
"integrity": "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==",
"cpu": [
"arm64"
],
@@ -8925,9 +8972,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.66.0.tgz",
"integrity": "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.67.0.tgz",
"integrity": "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==",
"cpu": [
"arm64"
],
@@ -8942,9 +8989,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.66.0.tgz",
"integrity": "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.67.0.tgz",
"integrity": "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==",
"cpu": [
"ia32"
],
@@ -8959,9 +9006,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.66.0.tgz",
"integrity": "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.67.0.tgz",
"integrity": "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==",
"cpu": [
"x64"
],
@@ -9152,13 +9199,13 @@
}
},
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz",
"integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
@@ -9442,6 +9489,20 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@rc-component/util": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz",
"integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==",
"license": "MIT",
"dependencies": {
"is-mobile": "^5.0.0",
"react-is": "^18.2.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/@react-dnd/asap": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
@@ -18934,9 +18995,9 @@
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.0.tgz",
"integrity": "sha512-qqGFOrKmFP1lTfG24opOJFcTMza1BqyTSUKVbMGUP5uRsBH+C00Q1loOk+JSFshyRE0ji4HtCJeNN2WHWd6PGw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -20750,9 +20811,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.20",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -21801,9 +21862,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"version": "5.22.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -22606,14 +22667,14 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.1",
"synckit": "^0.11.12"
"synckit": "^0.11.13"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -22644,9 +22705,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.2.tgz",
"integrity": "sha512-cqm9DXcsISYZHnFXT5zPH+ITsMx/bYscmq6zIsbtYvei1vj4dZ+BxN9LgoMmjEdm7sTaWxKVRY5IqQRQvau/GQ==",
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.4.tgz",
"integrity": "sha512-T6UFIOl2yWzVJ7LRk27z6EbJm2pfO4+VCTp2TBRsmAUREkDFUXjtWxoD9NsDcg6NmMFETZLbAD1XzV/w/GOmqw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -37424,9 +37485,9 @@
}
},
"node_modules/oxlint": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.66.0.tgz",
"integrity": "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw==",
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.67.0.tgz",
"integrity": "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -37439,32 +37500,36 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.66.0",
"@oxlint/binding-android-arm64": "1.66.0",
"@oxlint/binding-darwin-arm64": "1.66.0",
"@oxlint/binding-darwin-x64": "1.66.0",
"@oxlint/binding-freebsd-x64": "1.66.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.66.0",
"@oxlint/binding-linux-arm-musleabihf": "1.66.0",
"@oxlint/binding-linux-arm64-gnu": "1.66.0",
"@oxlint/binding-linux-arm64-musl": "1.66.0",
"@oxlint/binding-linux-ppc64-gnu": "1.66.0",
"@oxlint/binding-linux-riscv64-gnu": "1.66.0",
"@oxlint/binding-linux-riscv64-musl": "1.66.0",
"@oxlint/binding-linux-s390x-gnu": "1.66.0",
"@oxlint/binding-linux-x64-gnu": "1.66.0",
"@oxlint/binding-linux-x64-musl": "1.66.0",
"@oxlint/binding-openharmony-arm64": "1.66.0",
"@oxlint/binding-win32-arm64-msvc": "1.66.0",
"@oxlint/binding-win32-ia32-msvc": "1.66.0",
"@oxlint/binding-win32-x64-msvc": "1.66.0"
"@oxlint/binding-android-arm-eabi": "1.67.0",
"@oxlint/binding-android-arm64": "1.67.0",
"@oxlint/binding-darwin-arm64": "1.67.0",
"@oxlint/binding-darwin-x64": "1.67.0",
"@oxlint/binding-freebsd-x64": "1.67.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.67.0",
"@oxlint/binding-linux-arm-musleabihf": "1.67.0",
"@oxlint/binding-linux-arm64-gnu": "1.67.0",
"@oxlint/binding-linux-arm64-musl": "1.67.0",
"@oxlint/binding-linux-ppc64-gnu": "1.67.0",
"@oxlint/binding-linux-riscv64-gnu": "1.67.0",
"@oxlint/binding-linux-riscv64-musl": "1.67.0",
"@oxlint/binding-linux-s390x-gnu": "1.67.0",
"@oxlint/binding-linux-x64-gnu": "1.67.0",
"@oxlint/binding-linux-x64-musl": "1.67.0",
"@oxlint/binding-openharmony-arm64": "1.67.0",
"@oxlint/binding-win32-arm64-msvc": "1.67.0",
"@oxlint/binding-win32-ia32-msvc": "1.67.0",
"@oxlint/binding-win32-x64-msvc": "1.67.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.22.1"
"oxlint-tsgolint": ">=0.22.1",
"vite-plus": "*"
},
"peerDependenciesMeta": {
"oxlint-tsgolint": {
"optional": true
},
"vite-plus": {
"optional": true
}
}
},
@@ -39401,9 +39466,9 @@
}
},
"node_modules/query-string": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz",
"integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.0.tgz",
"integrity": "sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.4.1",
@@ -44375,13 +44440,13 @@
"license": "MIT"
},
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
"@pkgr/core": "^0.3.6"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -44531,9 +44596,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz",
"integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==",
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz",
"integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -47310,9 +47375,9 @@
}
},
"node_modules/webpack": {
"version": "5.107.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.1.tgz",
"integrity": "sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==",
"version": "5.107.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz",
"integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -47325,7 +47390,7 @@
"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",
@@ -47338,7 +47403,7 @@
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.5.0",
"watchpack": "^2.5.1",
"webpack-sources": "^3.4.1"
"webpack-sources": "^3.5.0"
},
"bin": {
"webpack": "bin/webpack.js"
@@ -49231,7 +49296,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.0",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
@@ -49294,9 +49359,10 @@
"version": "0.20.4",
"license": "Apache-2.0",
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@ant-design/icons": "^6.2.5",
"@apache-superset/core": "*",
"@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",
@@ -49311,14 +49377,14 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.20",
"dompurify": "^3.4.5",
"dayjs": "^1.11.21",
"dompurify": "^3.4.7",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
"lodash": "^4.18.1",
"math-expression-evaluator": "^2.0.7",
"pretty-ms": "^9.3.0",
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.5.0",
@@ -49326,7 +49392,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -49395,14 +49461,14 @@
}
},
"packages/superset-ui-core/node_modules/@ant-design/icons": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.3.tgz",
"integrity": "sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==",
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.5.tgz",
"integrity": "sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",
"@ant-design/icons-svg": "^4.4.2",
"@rc-component/util": "^1.10.1",
"@rc-component/util": "^1.11.0",
"clsx": "^2.1.1"
},
"engines": {
@@ -49413,29 +49479,6 @@
"react-dom": ">=16.0.0"
}
},
"packages/superset-ui-core/node_modules/@ant-design/icons/node_modules/@rc-component/util": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.1.tgz",
"integrity": "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng==",
"license": "MIT",
"dependencies": {
"is-mobile": "^5.0.0",
"react-is": "^18.2.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"packages/superset-ui-core/node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/superset-ui-core/node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -49446,9 +49489,9 @@
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -49827,7 +49870,7 @@
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.4.5",
"dompurify": "^3.4.7",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
@@ -49838,14 +49881,14 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"react": "^18.2.0"
}
},
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -49941,7 +49984,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"echarts": "*",
"memoize-one": "*",
"react": "^18.2.0"
@@ -49999,7 +50042,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"ace-builds": "^1.4.14",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"handlebars": "^4.7.8",
"lodash": "^4.18.1",
"react": "^18.2.0",
@@ -50215,7 +50258,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"mapbox-gl": ">=1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"

View File

@@ -169,10 +169,10 @@
"antd": "^5.26.0",
"chrono-node": "^2.9.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.0",
"content-disposition": "^2.0.1",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.20",
"dayjs": "^1.11.21",
"dom-to-image-more": "^3.7.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
@@ -201,8 +201,7 @@
"mustache": "^4.2.0",
"nanoid": "^5.1.11",
"ol": "^10.9.0",
"pretty-ms": "^9.3.0",
"query-string": "9.3.1",
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react-arborist": "^3.8.0",
@@ -246,9 +245,9 @@
"devDependencies": {
"@babel/cli": "^7.29.7",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.29.0",
"@babel/core": "^7.29.7",
"@babel/eslint-parser": "^7.29.7",
"@babel/node": "^7.29.0",
"@babel/node": "^7.29.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
@@ -256,12 +255,13 @@
"@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/register": "^7.29.7",
"@babel/runtime": "^7.29.7",
"@babel/runtime-corejs3": "^7.29.7",
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.3",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.60.0",
@@ -331,9 +331,9 @@
"eslint-plugin-jest-dom": "^5.5.0",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -353,7 +353,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.66.0",
"oxlint": "^1.67.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
@@ -367,7 +367,7 @@
"storybook": "8.6.18",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.0",
"terser-webpack-plugin": "^5.6.1",
"ts-jest": "^29.4.11",
"tscw-config": "^1.1.2",
"tsx": "^4.22.3",
@@ -375,7 +375,7 @@
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.10",
"webpack": "^5.107.1",
"webpack": "^5.107.2",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",

View File

@@ -74,7 +74,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.0",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",

View File

@@ -57,7 +57,7 @@ export const D3_FORMAT_OPTIONS: [string, string][] = [
...d3Formatted,
['DURATION', t('Duration in ms (66000 => 1m 6s)')],
['DURATION_SUB', t('Duration in ms (1.40008 => 1ms 400µs 80ns)')],
['DURATION_COL', t('Duration in ms (10500 => 0:10.5)')],
['DURATION_COL', t('Duration in ms (10500 => 0:00:10.5)')],
['MEMORY_DECIMAL', t('Memory in bytes - decimal (1024B => 1.024kB)')],
['MEMORY_BINARY', t('Memory in bytes - binary (1024B => 1KiB)')],
[

View File

@@ -24,7 +24,7 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@ant-design/icons": "^6.2.5",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.7",
"@braintree/sanitize-url": "^7.1.2",
@@ -42,14 +42,14 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.20",
"dompurify": "^3.4.5",
"dayjs": "^1.11.21",
"dompurify": "^3.4.7",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
"lodash": "^4.18.1",
"math-expression-evaluator": "^2.0.7",
"pretty-ms": "^9.3.0",
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.5.0",
@@ -57,7 +57,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",

View File

@@ -51,8 +51,16 @@ test('renders children with custom horizontal spacing', () => {
expect(screen.getByTestId('container')).toHaveStyle('gap: 20px');
});
test('does not render a dropdown button when not overflowing', () => {
test('renders dropdown button when items exist even when not overflowing', () => {
render(<DropdownContainer items={generateItems(3)} />);
// Button should always be visible when items exist to prevent layout shifts
expect(screen.getByText('More')).toBeInTheDocument();
// Badge should show 0 when nothing is overflowing
expect(screen.getByText('0')).toBeInTheDocument();
});
test('does not render a dropdown button when no items', () => {
render(<DropdownContainer items={[]} />);
expect(screen.queryByText('More')).not.toBeInTheDocument();
});

View File

@@ -34,7 +34,7 @@ import { t } from '@apache-superset/core/translation';
import { usePrevious } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { useResizeDetector } from 'react-resize-detector';
import { Badge, Icons, Button, Tooltip, Popover } from '..';
import { Badge, Icons, Button, Popover } from '..';
import { DropdownContainerProps, DropdownItem, DropdownRef } from './types';
const MAX_HEIGHT = 500;
@@ -234,6 +234,10 @@ export const DropdownContainer = forwardRef(
const overflowingCount =
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
// Always show button when items exist to prevent layout shifts
// and ensure consistent UI even when no items are overflowing
const shouldShowButton = items.length > 0 || !!dropdownContent;
const popoverContent = useMemo(
() =>
dropdownContent || overflowingCount ? (
@@ -293,6 +297,44 @@ export const DropdownContainer = forwardRef(
};
}, [popoverVisible]);
const triggerButton = (
<Button
buttonStyle="secondary"
data-test="dropdown-container-btn"
icon={dropdownTriggerIcon}
disabled={!popoverContent}
tooltip={dropdownTriggerTooltip}
css={css`
padding-left: ${theme.paddingXS}px;
padding-right: ${theme.paddingXXS}px;
gap: ${theme.sizeXXS}px;
`}
>
{dropdownTriggerText}
<Badge
count={dropdownTriggerCount ?? overflowingCount}
color={
(dropdownTriggerCount ?? overflowingCount) > 0
? theme.colorPrimary
: theme.colorTextSecondary
}
showZero
css={css`
margin-left: ${theme.sizeUnit * 2}px;
`}
/>
<Icons.DownOutlined
iconSize="m"
iconColor={theme.colorIcon}
css={css`
.anticon {
display: flex;
}
`}
/>
</Button>
);
return (
<div
ref={ref}
@@ -314,7 +356,7 @@ export const DropdownContainer = forwardRef(
>
{notOverflowedItems.map(item => item.element)}
</div>
{popoverContent && (
{shouldShowButton && (
<>
<Global
styles={css`
@@ -339,57 +381,27 @@ export const DropdownContainer = forwardRef(
`}
/>
<Popover
styles={{
body: {
maxHeight: `${MAX_HEIGHT}px`,
overflow: showOverflow ? 'auto' : 'visible',
},
}}
content={popoverContent}
trigger="click"
open={popoverVisible}
onOpenChange={visible => setPopoverVisible(visible)}
placement="bottom"
forceRender={forceRender}
fresh // This prop prevents caching and stale data for filter scoping.
>
<Tooltip title={dropdownTriggerTooltip}>
<Button
buttonStyle="secondary"
data-test="dropdown-container-btn"
icon={dropdownTriggerIcon}
css={css`
padding-left: ${theme.paddingXS}px;
padding-right: ${theme.paddingXXS}px;
gap: ${theme.sizeXXS}px;
`}
>
{dropdownTriggerText}
<Badge
count={dropdownTriggerCount ?? overflowingCount}
color={
(dropdownTriggerCount ?? overflowingCount) > 0
? theme.colorPrimary
: theme.colorTextSecondary
}
showZero
css={css`
margin-left: ${theme.sizeUnit * 2}px;
`}
/>
<Icons.DownOutlined
iconSize="m"
iconColor={theme.colorIcon}
css={css`
.anticon {
display: flex;
}
`}
/>
</Button>
</Tooltip>
</Popover>
{popoverContent ? (
<Popover
styles={{
body: {
maxHeight: `${MAX_HEIGHT}px`,
overflow: showOverflow ? 'auto' : 'visible',
},
}}
content={popoverContent}
trigger="click"
open={popoverVisible}
onOpenChange={visible => setPopoverVisible(visible)}
placement="bottom"
forceRender={forceRender}
fresh // This prop prevents caching and stale data for filter scoping.
>
{triggerButton}
</Popover>
) : (
triggerButton
)}
</>
)}
</div>

View File

@@ -31,6 +31,53 @@ interface SafeMarkdownProps {
htmlSchemaOverrides?: typeof defaultSchema;
}
// Link protocols that can execute script when used as an href.
const DANGEROUS_LINK_PROTOCOLS = ['javascript', 'vbscript', 'data'];
/**
* Sanitize link hrefs without using react-markdown's default protocol
* allowlist, which would strip the custom link schemes that Superset markdown
* is expected to support (see #26211). Instead of allowlisting known-safe
* protocols, this blocks the protocols that enable script execution and leaves
* everything else (http(s), mailto, relative URLs, anchors and custom schemes)
* untouched. Applied regardless of the EscapeMarkdownHtml feature flag.
*/
export function transformLinkUri(uri: string): string {
// Per the WHATWG URL parser, browsers strip leading C0 control
// characters (\x00-\x1f) and space before resolving the scheme, so e.g.
// "\x01javascript:alert(1)" executes on click. Strip them here too,
// otherwise the blocklist check below could be bypassed with a leading
// control character. The pattern is anchored at the start so it runs in
// linear time; trailing whitespace does not affect the scheme and is
// left for the renderer to handle.
// eslint-disable-next-line no-control-regex
const url = (uri || '').replace(/^[\u0000-\u0020]+/, '');
const first = url.charAt(0);
// Anchors and absolute/relative paths have no protocol.
if (first === '#' || first === '/') {
return url;
}
const colon = url.indexOf(':');
if (colon === -1) {
return url;
}
// A ':' after a '?' or '#' belongs to the query/fragment, not a scheme.
const queryIndex = url.indexOf('?');
if (queryIndex !== -1 && colon > queryIndex) {
return url;
}
const hashIndex = url.indexOf('#');
if (hashIndex !== -1 && colon > hashIndex) {
return url;
}
// Whitespace and C0 control characters inside the scheme (e.g.
// "java\tscript:" or "java\x01script:") are ignored by browsers, so strip
// them before comparing against the blocklist.
// eslint-disable-next-line no-control-regex
const scheme = url.slice(0, colon).replace(/[\u0000-\u0020]/g, '').toLowerCase();
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
}
export function getOverrideHtmlSchema(
originalSchema: typeof defaultSchema,
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
@@ -82,7 +129,7 @@ export function SafeMarkdown({
rehypePlugins={rehypePlugins}
remarkPlugins={[remarkGfm]}
skipHtml={false}
transformLinkUri={null}
transformLinkUri={transformLinkUri}
>
{source}
</ReactMarkdown>

View File

@@ -214,6 +214,12 @@ test('Bulk selection should work with pagination', () => {
// Check that selection checkboxes are rendered
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
// Guard: the select-all column header carries `data-test="header-toggle-all"`,
// which the `header.cell` slot keys on antd's internal `ant-table-selection-column`
// class. If antd renames that class, this assertion fails fast at the unit level
// instead of leaking into Playwright as a flake.
expect(screen.getByTestId('header-toggle-all')).toBeInTheDocument();
});
test('should call setSortBy when clicking sortable column header', () => {

View File

@@ -196,6 +196,14 @@ function TableCollection<T extends object>({
const rowSelection: TableRowSelection | undefined = useMemo(() => {
if (!bulkSelectEnabled) return undefined;
// antd Table's `rowSelection` API renders its own checkbox column.
// The select-all `data-test` lives on the `<th>` via `header.cell`
// below (keyed on antd's `ant-table-selection-column` className), NOT
// via `columnTitle` — rc-table's MeasureCell renders the column
// `title` verbatim inside `<tbody>`, so a `columnTitle` wrapper leaks
// any `data-test` attr into the measure row and breaks Playwright
// strict-mode selectors. `renderCell` only renders in real body rows,
// so wrapping per-row checkboxes there is safe.
return {
selectedRowKeys,
onSelect: (record, selected) => {
@@ -204,6 +212,9 @@ function TableCollection<T extends object>({
onSelectAll: (selected: boolean) => {
toggleAllRowsSelected?.(selected);
},
renderCell: (_value, _record, _index, originNode) => (
<span data-test="row-select-checkbox">{originNode}</span>
),
};
}, [
bulkSelectEnabled,
@@ -306,9 +317,18 @@ function TableCollection<T extends object>({
rowClassName={getRowClassName}
components={{
header: {
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
<th {...props} data-test="sort-header" />
),
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
const isSelectionColumn =
props.className?.includes('ant-table-selection-column') ?? false;
return (
<th
{...props}
data-test={
isSelectionColumn ? 'header-toggle-all' : 'sort-header'
}
/>
);
},
},
body: {
row: (props: HTMLAttributes<HTMLTableRowElement>) => (

View File

@@ -64,15 +64,15 @@ NumberFormats.PERCENT; // ,.2%
NumberFormats.PERCENT_3_POINT; // ,.3%
```
There is also a formatter based on [pretty-ms](https://www.npmjs.com/package/pretty-ms) that can be
There is also a formatter based on [Intl.DurationFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat) that can be
used to format time durations:
```js
import { createDurationFormatter, formatNumber, getNumberFormatterRegistry } from '@superset-ui-number-format';
getNumberFormatterRegistry().registerValue('my_duration_format', createDurationFormatter({ colonNotation: true });
getNumberFormatterRegistry().registerValue('my_duration_format', createDurationFormatter({ style: 'digital' }));
console.log(formatNumber('my_duration_format', 95500))
// prints '1:35.5'
// prints '0:01:35'
```
#### API

View File

@@ -17,8 +17,9 @@
* under the License.
*/
import prettyMilliseconds, { Options } from 'pretty-ms';
import NumberFormatter from '../NumberFormatter';
import { getIntlDurationFormatter } from '../utils/getIntlDurationFormatter';
import { parseMilliseconds } from '../utils/parseMilliseconds';
export default function createDurationFormatter(
config: {
@@ -26,14 +27,48 @@ export default function createDurationFormatter(
id?: string;
label?: string;
multiplier?: number;
} & Options = {},
locale?: string;
formatSubMilliseconds?: boolean;
} & Intl.DurationFormatOptions = {},
) {
const { description, id, label, multiplier = 1, ...prettyMsOptions } = config;
const {
description,
id,
label,
multiplier = 1,
locale,
formatSubMilliseconds = false,
...intlOptions
} = config;
const durationFormatter = getIntlDurationFormatter(locale, {
secondsDisplay: 'auto',
style: 'narrow',
...intlOptions,
});
const zeroDurationFormatter = getIntlDurationFormatter(locale, {
secondsDisplay: 'always',
style: 'narrow',
...intlOptions,
});
return new NumberFormatter({
description,
formatFunc: value =>
prettyMilliseconds(value * multiplier, prettyMsOptions),
formatFunc: value => {
const durObject = parseMilliseconds(value * multiplier);
if (!formatSubMilliseconds) {
durObject.milliseconds = 0;
durObject.microseconds = 0;
durObject.nanoseconds = 0;
}
const isAllUnitsZero = Object.values(durObject).every(
value => value === 0,
);
return (
isAllUnitsZero ? zeroDurationFormatter : durationFormatter
).format(durObject);
},
id: id ?? 'duration_format',
label: label ?? `Duration formatter`,
});

View File

@@ -0,0 +1,30 @@
/**
* 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.
*/
export function getIntlDurationFormatter(
locale?: string,
options?: Intl.DurationFormatOptions,
): Intl.DurationFormat {
const normalizedLocale = locale?.replace(/_/g, '-');
try {
return new Intl.DurationFormat(normalizedLocale, options);
} catch {
return new Intl.DurationFormat('en', options);
}
}

View File

@@ -0,0 +1,57 @@
/**
* 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 parseMs from 'parse-ms';
interface Duration {
years: number;
days: number;
hours: number;
minutes: number;
seconds: number;
milliseconds: number;
microseconds: number;
nanoseconds: number;
}
const DAYS_IN_YEAR = 365;
/**
* Parses milliseconds into a duration object.
* @param ms - The number of milliseconds to parse
* @returns A duration object containing years, days, hours, minutes, seconds,
* milliseconds, microseconds, and nanoseconds (1 year = 365 days)
* @example
* // Parse a complex duration
* parseMilliseconds(90061000);
* // { years: 0, days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 0, ... }
*/
export function parseMilliseconds(ms: number): Duration {
const parsed = parseMs(ms);
const totalDays = parsed.days;
const years = Math.trunc(totalDays / DAYS_IN_YEAR);
const remainingDays = totalDays % DAYS_IN_YEAR;
return {
...parsed,
years,
days: remainingDays,
};
}

View File

@@ -102,7 +102,6 @@ export type ChartCustomization = {
defaultDataMask: DataMask;
controlValues: {
sortAscending?: boolean;
sortMetric?: string;
[key: string]: any;
};
description?: string;

View File

@@ -20,6 +20,7 @@ import { render } from '@testing-library/react';
import {
getOverrideHtmlSchema,
SafeMarkdown,
transformLinkUri,
} from '../../src/components/SafeMarkdown/SafeMarkdown';
/**
@@ -52,6 +53,63 @@ describe('getOverrideHtmlSchema', () => {
});
});
describe('transformLinkUri', () => {
// Build script-executing protocols via concatenation so the literal URLs
// don't trip the no-script-url lint rule.
const js = `java${'script'}`;
const vbs = `vb${'script'}`;
// Cases are [label, uri] pairs: the raw URIs contain C0 control characters
// (\x00, \x01, \x1F) that are invalid in XML, so they must not be
// interpolated into the test name (the HTML/JUnit reporters serialize names
// to XML and would crash). The label keeps the reported name printable while
// the uri is exercised in the body.
test.each([
['javascript', `${js}:alert(1)`],
['mixed-case JavaScript', `Java${'Script'}:alert(1)`],
['leading whitespace', ` ${js}:alert(document.cookie)`],
['tab inside scheme', `java\t${'script'}:alert(1)`],
// Leading C0 control characters are stripped by the WHATWG URL parser
// before the scheme is resolved, so they must not bypass the blocklist.
['leading 0x01 control', `\x01${js}:alert(1)`],
['leading NUL (0x00)', `\x00${js}:alert(1)`],
['leading 0x1F control', `\x1F${js}:alert(1)`],
// C0 control characters inside the scheme are ignored by browsers too.
['0x01 control inside scheme', `java\x01${'script'}:alert(1)`],
['vbscript', `${vbs}:msgbox(1)`],
['data: text/html', 'data:text/html,<script>alert(1)</script>'],
])(
'blocks the script-executing protocol (%s)',
(_label: string, uri: string) => {
expect(transformLinkUri(uri)).toBe('');
},
);
test.each([
'https://superset.apache.org',
'http://example.com/path?q=1',
'mailto:someone@example.com',
'/relative/path',
'#section',
])('keeps the safe URL %p unchanged', uri => {
expect(transformLinkUri(uri)).toBe(uri);
});
test.each([
'custom-scheme://open/thing',
'slack://channel?id=1',
`foo:bar?${js}:alert(1)`,
])('preserves custom link scheme %p (see #26211)', uri => {
expect(transformLinkUri(uri)).toBe(uri);
});
test('handles empty and nullish input', () => {
expect(transformLinkUri('')).toBe('');
// @ts-expect-error -- guarding runtime nullish input
expect(transformLinkUri(undefined)).toBe('');
});
});
describe('SafeMarkdown', () => {
describe('remark-gfm compatibility tests', () => {
/**

View File

@@ -26,34 +26,31 @@ test('creates an instance of NumberFormatter', () => {
test('format milliseconds in human readable format with default options', () => {
const formatter = createDurationFormatter();
expect(formatter(-1000)).toBe('-1s');
expect(formatter(0)).toBe('0ms');
expect(formatter(0)).toBe('0s');
expect(formatter(1000)).toBe('1s');
expect(formatter(1337)).toBe('1.3s');
expect(formatter(10500)).toBe('10.5s');
expect(formatter(1337)).toBe('1s');
expect(formatter(10500)).toBe('10s');
expect(formatter(60 * 1000)).toBe('1m');
expect(formatter(90 * 1000)).toBe('1m 30s');
});
test('format seconds in human readable format with default options', () => {
const formatter = createDurationFormatter({ multiplier: 1000 });
expect(formatter(-0.5)).toBe('-500ms');
expect(formatter(0.5)).toBe('500ms');
expect(formatter(-0.5)).toBe('-0s');
expect(formatter(0.5)).toBe('0s');
expect(formatter(1)).toBe('1s');
expect(formatter(30)).toBe('30s');
expect(formatter(60)).toBe('1m');
expect(formatter(90)).toBe('1m 30s');
});
test('format milliseconds in human readable format with additional pretty-ms options', () => {
test('format milliseconds in human readable format with additional options', () => {
const colonNotationFormatter = createDurationFormatter({
colonNotation: true,
style: 'digital',
formatSubMilliseconds: true,
fractionalDigits: 1,
});
expect(colonNotationFormatter(-10500)).toBe('-0:10.5');
expect(colonNotationFormatter(10500)).toBe('0:10.5');
const zeroDecimalFormatter = createDurationFormatter({
secondsDecimalDigits: 0,
});
expect(zeroDecimalFormatter(10500)).toBe('10s');
expect(colonNotationFormatter(10500)).toBe('0:00:10.5');
const subMillisecondFormatter = createDurationFormatter({
formatSubMilliseconds: true,
});
expect(subMillisecondFormatter(100.40008)).toBe('100ms 400µs 80ns');
expect(subMillisecondFormatter(100.40008)).toBe('100ms 400μs 80ns');
});

View File

@@ -0,0 +1,38 @@
/**
* 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 { getIntlDurationFormatter } from '@superset-ui/core/number-format/utils/getIntlDurationFormatter';
test('getIntlDurationFormatter creates formatter with fallback locale when passed locale is invalid', () => {
const formatter = getIntlDurationFormatter('invalid-locale-xyz');
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
expect(formatter.format({ seconds: 60 })).toBe('60 sec');
});
test('getIntlDurationFormatter creates formatter with custom options', () => {
const formatter = getIntlDurationFormatter('en', { style: 'digital' });
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
expect(formatter.format({ minutes: 5, seconds: 30 })).toContain(':');
});
test('getIntlDurationFormatter normalizes locale underscores', () => {
const formatter = getIntlDurationFormatter('zh_Hans_CN');
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
expect(formatter.resolvedOptions().locale).toMatch(/^zh/);
});

View File

@@ -0,0 +1,148 @@
/*
* 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 { parseMilliseconds } from '@superset-ui/core/number-format/utils/parseMilliseconds';
test('parseMilliseconds should parse basic time units correctly', () => {
expect(parseMilliseconds(500)).toEqual({
years: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 500,
microseconds: 0,
nanoseconds: 0,
});
expect(parseMilliseconds(5000)).toEqual({
years: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 5,
milliseconds: 0,
microseconds: 0,
nanoseconds: 0,
});
expect(parseMilliseconds(120000)).toEqual({
years: 0,
days: 0,
hours: 0,
minutes: 2,
seconds: 0,
milliseconds: 0,
microseconds: 0,
nanoseconds: 0,
});
expect(parseMilliseconds(7200000)).toEqual({
years: 0,
days: 0,
hours: 2,
minutes: 0,
seconds: 0,
milliseconds: 0,
microseconds: 0,
nanoseconds: 0,
});
expect(parseMilliseconds(172800000)).toEqual({
years: 0,
days: 2,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
microseconds: 0,
nanoseconds: 0,
});
expect(parseMilliseconds(31536000000)).toEqual({
years: 1,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
microseconds: 0,
nanoseconds: 0,
});
});
test('parseMilliseconds should handle complex duration', () => {
expect(parseMilliseconds(90061234)).toEqual({
years: 0,
days: 1,
hours: 1,
minutes: 1,
seconds: 1,
milliseconds: 234,
microseconds: 0,
nanoseconds: 0,
});
});
test('parseMilliseconds should handle fractional milliseconds', () => {
expect(parseMilliseconds(1.001001)).toEqual({
years: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 1,
microseconds: 1,
nanoseconds: 1,
});
});
test('parseMilliseconds should handle zero', () => {
expect(parseMilliseconds(0)).toEqual({
years: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
microseconds: 0,
nanoseconds: 0,
});
});
test('parseMilliseconds should handle negative duration', () => {
expect(parseMilliseconds(-1000)).toEqual({
years: -0,
days: -0,
hours: -0,
minutes: -0,
seconds: -1,
milliseconds: -0,
microseconds: -0,
nanoseconds: -0,
});
});
test('parseMilliseconds should handle negative days without overflowing into years', () => {
expect(parseMilliseconds(-31449600000)).toEqual({
years: -0,
days: -364,
hours: -0,
minutes: -0,
seconds: -0,
milliseconds: -0,
microseconds: -0,
nanoseconds: -0,
});
});

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.
*/
declare namespace Intl {
class DurationFormat {
constructor(locale?: string | string[], options?: DurationFormatOptions);
format(duration: DurationObject): string;
formatToParts(
duration: DurationObject,
): { type: string; value: string; unit?: string }[];
resolvedOptions(): ResolvedDurationFormatOptions;
}
interface DurationObject {
years?: number;
months?: number;
weeks?: number;
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
milliseconds?: number;
microseconds?: number;
nanoseconds?: number;
}
interface DurationFormatOptions {
localeMatcher?: 'lookup' | 'best fit';
numberingSystem?: string;
style?: 'long' | 'short' | 'narrow' | 'digital';
years?: 'long' | 'short' | 'narrow';
yearsDisplay?: 'always' | 'auto';
months?: 'long' | 'short' | 'narrow';
monthsDisplay?: 'always' | 'auto';
weeks?: 'long' | 'short' | 'narrow';
weeksDisplay?: 'always' | 'auto';
days?: 'long' | 'short' | 'narrow';
daysDisplay?: 'always' | 'auto';
hours?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
hoursDisplay?: 'always' | 'auto';
minutes?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
minutesDisplay?: 'always' | 'auto';
seconds?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
secondsDisplay?: 'always' | 'auto';
milliseconds?: 'long' | 'short' | 'narrow' | 'numeric';
millisecondsDisplay?: 'always' | 'auto';
microseconds?: 'long' | 'short' | 'narrow' | 'numeric';
microsecondsDisplay?: 'always' | 'auto';
nanoseconds?: 'long' | 'short' | 'narrow' | 'numeric';
nanosecondsDisplay?: 'always' | 'auto';
fractionalDigits?: number;
}
interface ResolvedDurationFormatOptions extends DurationFormatOptions {
locale: string;
}
}

View File

@@ -17,14 +17,24 @@
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { Locator, Page, expect } from '@playwright/test';
import { Button, Checkbox, Table } from '../core';
const BULK_SELECT_SELECTORS = {
CONTROLS: '[data-test="bulk-select-controls"]',
ACTION: '[data-test="bulk-select-action"]',
HEADER_TOGGLE: '[data-test="header-toggle-all"]',
ROW_CHECKBOX: '[data-test="row-select-checkbox"]',
} as const;
/**
* Stable keys for ListView bulk actions, matching `action.key` in the
* `bulkActions` prop passed to `ListView` (see `src/pages/*List`). Using
* the key — not the localized button text — keeps selectors valid across
* locales.
*/
export type BulkSelectActionKey = 'delete' | 'export';
/**
* BulkSelect component for Superset ListView bulk operations.
* Provides a reusable interface for bulk selection and actions across list pages.
@@ -34,7 +44,7 @@ const BULK_SELECT_SELECTORS = {
* await bulkSelect.enable();
* await bulkSelect.selectRow('my-dataset');
* await bulkSelect.selectRow('another-dataset');
* await bulkSelect.clickAction('Delete');
* await bulkSelect.clickAction('delete');
*/
export class BulkSelect {
private readonly page: Page;
@@ -56,35 +66,67 @@ export class BulkSelect {
}
/**
* Enables bulk selection mode by clicking the toggle button
* Enables bulk selection mode by clicking the toggle button.
*
* Waits for the bulk-select column header to render so the next row
* interaction does not race the table re-render that adds the checkbox
* column. The `data-test="header-toggle-all"` attribute is on the
* select-all `<th>` itself (see `TableCollection`'s `components.header.cell`
* slot, which keys on antd's `ant-table-selection-column` className).
* It deliberately is NOT injected via `rowSelection.columnTitle` because
* rc-table's measure row in `<tbody>` clones `columnTitle` and any
* `data-test` would duplicate, breaking Playwright strict mode.
*/
async enable(): Promise<void> {
await this.getToggleButton().click();
await this.page.locator(BULK_SELECT_SELECTORS.HEADER_TOGGLE).waitFor();
}
/**
* Gets the checkbox for a row by name
* Gets the bulk-select checkbox for a row by name.
*
* The `data-test="row-select-checkbox"` attribute is on the `<span>`
* wrapper that `TableCollection`'s `rowSelection.renderCell` puts around
* antd's checkbox originNode (the attribute can't be moved directly
* onto antd's `<input>` from `renderCell` because the originNode is
* opaque). We drill into `input[type="checkbox"]` so Playwright's
* `.check()` operates on the real input — `.check()` on the wrapper
* `<span>` throws "Not a checkbox or radio button".
*
* @param rowName - The name/text identifying the row
*/
getRowCheckbox(rowName: string): Checkbox {
const row = this.table.getRow(rowName);
return new Checkbox(this.page, row.getByRole('checkbox'));
return new Checkbox(
this.page,
row.locator(
`${BULK_SELECT_SELECTORS.ROW_CHECKBOX} input[type="checkbox"]`,
),
);
}
/**
* Selects a row's checkbox in bulk select mode
* Selects a row's checkbox in bulk select mode.
* Asserts the checkbox is checked afterwards so any state-update race
* surfaces here rather than as a missing bulk-action button later.
* @param rowName - The name/text identifying the row to select
*/
async selectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).check();
const checkbox = this.getRowCheckbox(rowName);
await checkbox.check();
await expect(checkbox.element).toBeChecked();
}
/**
* Deselects a row's checkbox in bulk select mode
* Deselects a row's checkbox in bulk select mode.
* Mirrors selectRow: asserts the unchecked state so any lingering selection
* surfaces here rather than as a stale bulk-action count later.
* @param rowName - The name/text identifying the row to deselect
*/
async deselectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).uncheck();
const checkbox = this.getRowCheckbox(rowName);
await checkbox.uncheck();
await expect(checkbox.element).not.toBeChecked();
}
/**
@@ -95,22 +137,30 @@ export class BulkSelect {
}
/**
* Gets a bulk action button by name
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
* Gets a bulk action button by its stable action key.
*
* Scoping by `data-test-action-key` (rendered from `action.key`) instead
* of visible text keeps this selector valid across locales — the
* button's label is localized via i18n, but the action key is not.
*
* @param actionKey - The stable key of the bulk action (e.g., "delete", "export")
*/
getActionButton(actionName: string): Button {
getActionButton(actionKey: BulkSelectActionKey): Button {
const controls = this.getControls();
return new Button(
this.page,
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
controls.locator(
`${BULK_SELECT_SELECTORS.ACTION}[data-test-action-key="${actionKey}"]`,
),
);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
* Clicks a bulk action button by its stable action key.
* @param actionKey - The stable key of the bulk action to click
*/
async clickAction(actionName: string): Promise<void> {
await this.getActionButton(actionName).click();
async clickAction(actionKey: BulkSelectActionKey): Promise<void> {
const button = this.getActionButton(actionKey);
await button.click();
}
}

View File

@@ -19,3 +19,4 @@
// ListView-specific Playwright Components for Superset
export { BulkSelect } from './BulkSelect';
export type { BulkSelectActionKey } from './BulkSelect';

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { expect } from '@playwright/test';
import { Modal, Input } from '../core';
/**
@@ -27,7 +28,8 @@ import { Modal, Input } from '../core';
*/
export class DeleteConfirmationModal extends Modal {
private static readonly SELECTORS = {
CONFIRMATION_INPUT: 'input[type="text"]',
CONFIRMATION_INPUT: '[data-test="delete-modal-input"]',
CONFIRM_BUTTON: '[data-test="modal-confirm-button"]',
};
/**
@@ -36,12 +38,16 @@ export class DeleteConfirmationModal extends Modal {
private get confirmationInput(): Input {
return new Input(
this.page,
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
this.element.locator(
DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT,
),
);
}
/**
* Fills the confirmation input with the specified text.
* Waits for the input to be visible before filling so callers don't race
* with the modal's open animation / focus effect.
*
* @param confirmationText - The text to type
* @param options - Optional fill options (timeout, force)
@@ -57,11 +63,25 @@ export class DeleteConfirmationModal extends Modal {
confirmationText: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.confirmationInput.element.waitFor({
state: 'visible',
timeout: options?.timeout,
});
await this.confirmationInput.fill(confirmationText, options);
}
/**
* Clicks the Delete button in the footer
* Clicks the Delete button in the footer.
*
* Targets the confirm button by data-test rather than going through
* Modal.clickFooterButton, which finds buttons by their visible text. The
* button label is i18n'd ("Delete" / "Supprimer" / …) so name-based lookups
* break in non-English locales.
*
* Also waits for the button to become enabled before clicking: it is
* disabled until the confirmation text matches "DELETE", and React's state
* update from fillConfirmationInput is asynchronous, so an immediate click
* can race the disabled→enabled transition.
*
* @param options - Optional click options (timeout, force, delay)
*/
@@ -70,6 +90,10 @@ export class DeleteConfirmationModal extends Modal {
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Delete', options);
const confirmButton = this.element.locator(
DeleteConfirmationModal.SELECTORS.CONFIRM_BUTTON,
);
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
await confirmButton.click(options);
}
}

View File

@@ -19,7 +19,7 @@
import { Page, Locator } from '@playwright/test';
import { Table } from '../components/core';
import { BulkSelect } from '../components/ListView';
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
import { gotoWithRetry } from '../helpers/navigation';
import { URL } from '../utils/urls';
@@ -32,13 +32,12 @@ export class ChartListPage {
readonly bulkSelect: BulkSelect;
/**
* Action button names for getByRole('button', { name })
* Verified: ChartList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
* Stable data-test keys for the row action buttons in ChartList.
*/
private static readonly ACTION_BUTTONS = {
DELETE: 'delete',
EDIT: 'edit',
EXPORT: 'upload',
private static readonly ACTION_TEST_IDS = {
DELETE: 'chart-row-delete',
EDIT: 'chart-row-edit',
EXPORT: 'chart-row-export',
} as const;
constructor(page: Page) {
@@ -98,9 +97,7 @@ export class ChartListPage {
*/
async clickDeleteAction(chartName: string): Promise<void> {
const row = this.table.getRow(chartName);
await row
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.DELETE })
.click();
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.DELETE).click();
}
/**
@@ -109,9 +106,7 @@ export class ChartListPage {
*/
async clickEditAction(chartName: string): Promise<void> {
const row = this.table.getRow(chartName);
await row
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EDIT })
.click();
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EDIT).click();
}
/**
@@ -120,9 +115,7 @@ export class ChartListPage {
*/
async clickExportAction(chartName: string): Promise<void> {
const row = this.table.getRow(chartName);
await row
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EXPORT })
.click();
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EXPORT).click();
}
/**
@@ -141,11 +134,11 @@ export class ChartListPage {
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
* @param actionKey - The stable key of the bulk action to click
*/
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
await this.bulkSelect.clickAction(actionKey);
}
// --- Card view methods ---

View File

@@ -19,7 +19,7 @@
import { Page, Locator } from '@playwright/test';
import { Button, Table } from '../components/core';
import { BulkSelect } from '../components/ListView';
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
import { gotoWithRetry } from '../helpers/navigation';
import { URL } from '../utils/urls';
@@ -32,13 +32,12 @@ export class DashboardListPage {
readonly bulkSelect: BulkSelect;
/**
* Action button names for getByRole('button', { name })
* DashboardList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
* Stable data-test keys for the row action buttons in DashboardList.
*/
private static readonly ACTION_BUTTONS = {
DELETE: 'delete',
EDIT: 'edit',
EXPORT: 'upload',
private static readonly ACTION_TEST_IDS = {
DELETE: 'dashboard-row-delete',
EDIT: 'dashboard-row-edit',
EXPORT: 'dashboard-row-export',
} as const;
constructor(page: Page) {
@@ -81,9 +80,7 @@ export class DashboardListPage {
*/
async clickDeleteAction(dashboardName: string): Promise<void> {
const row = this.table.getRow(dashboardName);
await row
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.DELETE })
.click();
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.DELETE).click();
}
/**
@@ -92,9 +89,7 @@ export class DashboardListPage {
*/
async clickEditAction(dashboardName: string): Promise<void> {
const row = this.table.getRow(dashboardName);
await row
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EDIT })
.click();
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EDIT).click();
}
/**
@@ -103,9 +98,7 @@ export class DashboardListPage {
*/
async clickExportAction(dashboardName: string): Promise<void> {
const row = this.table.getRow(dashboardName);
await row
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EXPORT })
.click();
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EXPORT).click();
}
/**
@@ -124,11 +117,11 @@ export class DashboardListPage {
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
* @param actionKey - The stable key of the bulk action to click
*/
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
await this.bulkSelect.clickAction(actionKey);
}
/**

View File

@@ -19,7 +19,7 @@
import { Page, Locator } from '@playwright/test';
import { Button, Table } from '../components/core';
import { BulkSelect } from '../components/ListView';
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
import { gotoWithRetry } from '../helpers/navigation';
import { URL } from '../utils/urls';
@@ -36,13 +36,14 @@ export class DatasetListPage {
} as const;
/**
* Action button names for getByRole('button', { name })
* Stable data-test keys for the row action buttons in DatasetList
* (shared with the semantic-view rendering since only one renders per row).
*/
private static readonly ACTION_BUTTONS = {
DELETE: 'delete',
EDIT: 'edit',
EXPORT: 'upload', // Export button uses upload icon
DUPLICATE: 'copy',
private static readonly ACTION_TEST_IDS = {
DELETE: 'dataset-row-delete',
EDIT: 'dataset-row-edit',
EXPORT: 'dataset-row-export',
DUPLICATE: 'dataset-row-duplicate',
} as const;
constructor(page: Page) {
@@ -97,9 +98,7 @@ export class DatasetListPage {
*/
async clickDeleteAction(datasetName: string): Promise<void> {
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DELETE })
.click();
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DELETE).click();
}
/**
@@ -108,9 +107,7 @@ export class DatasetListPage {
*/
async clickEditAction(datasetName: string): Promise<void> {
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EDIT })
.click();
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EDIT).click();
}
/**
@@ -119,9 +116,7 @@ export class DatasetListPage {
*/
async clickExportAction(datasetName: string): Promise<void> {
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EXPORT })
.click();
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EXPORT).click();
}
/**
@@ -130,9 +125,7 @@ export class DatasetListPage {
*/
async clickDuplicateAction(datasetName: string): Promise<void> {
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DUPLICATE })
.click();
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DUPLICATE).click();
}
/**
@@ -151,11 +144,11 @@ export class DatasetListPage {
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
* @param actionKey - The stable key of the bulk action to click
*/
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
await this.bulkSelect.clickAction(actionKey);
}
/**

View File

@@ -32,6 +32,7 @@ import {
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
@@ -62,8 +63,11 @@ test('should delete a chart with confirmation', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click delete action button
await chartListPage.clickDeleteAction(chartName);
@@ -81,12 +85,14 @@ test('should delete a chart with confirmation', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify chart is removed from list
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
// Verify chart is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
await expect(chartListPage.getChartRow(chartName)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.CHART, chartId, {
@@ -111,8 +117,11 @@ test('should edit chart name via properties modal', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click edit action to open properties modal
await chartListPage.clickEditAction(chartName);
@@ -137,7 +146,7 @@ test('should edit chart name via properties modal', async ({
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
@@ -164,8 +173,11 @@ test('should export a chart as a zip file', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
@@ -186,7 +198,7 @@ test('should bulk delete multiple charts', async ({
chartListPage,
testAssets,
}) => {
test.setTimeout(60_000);
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway charts for bulk delete
const [chart1, chart2] = await Promise.all([
@@ -202,9 +214,14 @@ test('should bulk delete multiple charts', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created charts appear.
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
@@ -214,7 +231,7 @@ test('should bulk delete multiple charts', async ({
await chartListPage.selectChartCheckbox(chart2.name);
// Click bulk delete action
await chartListPage.clickBulkAction('Delete');
await chartListPage.clickBulkAction('delete');
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
@@ -229,13 +246,17 @@ test('should bulk delete multiple charts', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both charts are removed from list
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
// Verify both charts are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
await expect(chartListPage.getChartRow(chart1.name)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
await expect(chartListPage.getChartRow(chart2.name)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
// Backend verification: Both return 404
for (const chart of [chart1, chart2]) {
@@ -259,8 +280,11 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
await cardListPage.gotoCardView();
await cardListPage.waitForCardLoad();
// Verify chart card is visible
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart card appears.
await expect(cardListPage.getChartCard(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Open card dropdown and click edit
await cardListPage.clickCardEditAction(chartName);
@@ -285,13 +309,18 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify the renamed card appears in card view and old name is gone
await expect(cardListPage.getChartCard(newName)).toBeVisible();
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
// (the old card name is removed from the DOM after the rename re-render).
await expect(cardListPage.getChartCard(newName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(cardListPage.getChartCard(chartName)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
@@ -304,6 +333,11 @@ test('should bulk export multiple charts', async ({
chartListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway charts for bulk export
const [chart1, chart2] = await Promise.all([
createTestChart(page, testAssets, test.info(), {
@@ -318,9 +352,14 @@ test('should bulk export multiple charts', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created charts appear.
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
@@ -329,11 +368,15 @@ test('should bulk export multiple charts', async ({
await chartListPage.selectChartCheckbox(chart1.name);
await chartListPage.selectChartCheckbox(chart2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple charts can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Click bulk export action
await chartListPage.clickBulkAction('Export');
await chartListPage.clickBulkAction('export');
// Wait for export API response and validate zip contains both charts
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);

View File

@@ -68,8 +68,11 @@ test('should delete a dashboard with confirmation', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click delete action button
await dashboardListPage.clickDeleteAction(dashboardName);
@@ -81,20 +84,25 @@ test('should delete a dashboard with confirmation', async ({
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
// Click the Delete button (waits for it to become enabled)
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify dashboard is removed from list
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
// Verify dashboard is removed from list (extended timeout for slow CI
// post-delete propagation — the default 8s expect.timeout intermittently
// expires before the listview re-fetch lands).
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
0,
{
timeout: TIMEOUT.API_RESPONSE,
},
);
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
@@ -119,8 +127,11 @@ test('should export a dashboard as a zip file', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
@@ -141,7 +152,7 @@ test('should bulk delete multiple dashboards', async ({
dashboardListPage,
testAssets,
}) => {
test.setTimeout(60_000);
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway dashboards for bulk delete
const [dashboard1, dashboard2] = await Promise.all([
@@ -157,13 +168,14 @@ test('should bulk delete multiple dashboards', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboards appear.
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await dashboardListPage.clickBulkSelectButton();
@@ -173,7 +185,7 @@ test('should bulk delete multiple dashboards', async ({
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
// Click bulk delete action
await dashboardListPage.clickBulkAction('Delete');
await dashboardListPage.clickBulkAction('delete');
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
@@ -188,17 +200,19 @@ test('should bulk delete multiple dashboards', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both dashboards are removed from list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).not.toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).not.toBeVisible();
// Verify both dashboards are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toHaveCount(
0,
{ timeout: TIMEOUT.API_RESPONSE },
);
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toHaveCount(
0,
{ timeout: TIMEOUT.API_RESPONSE },
);
// Backend verification: Both return 404
for (const dashboard of [dashboard1, dashboard2]) {
@@ -213,6 +227,11 @@ test('should bulk export multiple dashboards', async ({
dashboardListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway dashboards for bulk export
const [dashboard1, dashboard2] = await Promise.all([
createTestDashboard(page, testAssets, test.info(), {
@@ -227,26 +246,31 @@ test('should bulk export multiple dashboards', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboards appear.
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
// Enable bulk select mode (waits for the checkbox column to render)
await dashboardListPage.clickBulkSelectButton();
// Select both dashboards
// Select both dashboards (each call asserts the checkbox is checked)
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple dashboards can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Click bulk export action
await dashboardListPage.clickBulkAction('Export');
// Click bulk export action (waits for the action button to render)
await dashboardListPage.clickBulkAction('export');
// Wait for export API response and validate zip contains both dashboards
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
@@ -262,14 +286,15 @@ test('should bulk export multiple dashboards', async ({
// this prevents race conditions when parallel workers import the same dashboard.
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('import dashboard', () => {
test.describe.configure({ mode: 'serial' });
// `timeout` on describe.configure also bounds fixture setup, so the
// `dashboardListPage` navigation gets the SLOW_TEST budget too —
// inline `test.setTimeout()` only applies once the test body runs.
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
test('should import a dashboard from a zip file', async ({
page,
dashboardListPage,
testAssets,
}) => {
test.setTimeout(60_000);
// Create a dashboard, export it via API, then delete it, then reimport via UI
const { id: dashboardId, name: dashboardName } = await createTestDashboard(
page,
@@ -293,12 +318,13 @@ test.describe('import dashboard', () => {
label: `Dashboard ${dashboardId}`,
});
// Refresh to confirm dashboard is no longer in the list
// Refresh to confirm dashboard is no longer in the list (deleted rows are removed from the DOM, so assert count rather than visibility)
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
0,
{ timeout: TIMEOUT.API_RESPONSE },
);
// Click the import button
await dashboardListPage.clickImportButton();
@@ -328,7 +354,7 @@ test.describe('import dashboard', () => {
// Handle overwrite confirmation if dashboard already exists
const overwriteInput = importModal.getOverwriteInput();
await overwriteInput
.waitFor({ state: 'visible', timeout: 3000 })
.waitFor({ state: 'visible', timeout: TIMEOUT.CONFIRM_DIALOG })
.catch(error => {
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
@@ -350,18 +376,21 @@ test.describe('import dashboard', () => {
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
await expect(toast.getSuccess()).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
// Refresh to see the imported dashboard
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard appears in list
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-imported dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Track for cleanup: look up the reimported dashboard by title
const reimported = await getDashboardByName(page, dashboardName);

View File

@@ -107,8 +107,11 @@ test('should delete a dataset with confirmation', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click delete action button
await datasetListPage.clickDeleteAction(datasetName);
@@ -126,14 +129,15 @@ test('should delete a dataset with confirmation', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears with correct message
// Verify success toast appears with correct message.
const toast = new Toast(page);
const successToast = toast.getSuccess();
await expect(successToast).toBeVisible();
await expect(toast.getSuccess()).toBeVisible();
await expect(toast.getMessage()).toContainText('Deleted');
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Verify dataset is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
// Verify via API that dataset no longer exists (404)
await expectDeleted(page, ENDPOINTS.DATASET, datasetId, {
@@ -155,10 +159,13 @@ test('should duplicate a dataset with new name', async ({
);
const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
// Navigate to list and verify original dataset is visible
// Navigate to list and verify original dataset is visible.
// The list query is asynchronous; allow extra time on slow CI.
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Set up response intercept to capture duplicate dataset ID
const duplicateResponsePromise = waitForPost(
@@ -201,9 +208,14 @@ test('should duplicate a dataset with new name', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets exist in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// duplicate appears alongside the original.
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// API Verification: Fetch both datasets via detail API for consistent comparison
// (list API may return undefined for fields that detail API returns as null)
@@ -256,6 +268,11 @@ test('should export multiple datasets via bulk select action', async ({
datasetListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway datasets for bulk export
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
@@ -270,9 +287,14 @@ test('should export multiple datasets via bulk select action', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created datasets appear.
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
@@ -281,11 +303,15 @@ test('should export multiple datasets via bulk select action', async ({
await datasetListPage.selectDatasetCheckbox(dataset1.name);
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple datasets can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Click bulk export action
await datasetListPage.clickBulkAction('Export');
await datasetListPage.clickBulkAction('export');
// Wait for export API response and validate zip contains multiple datasets
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
@@ -312,8 +338,11 @@ test('should edit dataset name via modal', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click edit action to open modal
await datasetListPage.clickEditAction(datasetName);
@@ -348,9 +377,9 @@ test('should edit dataset name via modal', async ({
// Modal should close
await editModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
await expect(toast.getSuccess()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// Verify via API that name was saved
const updatedDatasetRes = await apiGetDataset(page, datasetId);
@@ -363,6 +392,8 @@ test('should bulk delete multiple datasets', async ({
datasetListPage,
testAssets,
}) => {
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway datasets for bulk delete
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
@@ -377,9 +408,14 @@ test('should bulk delete multiple datasets', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created datasets appear.
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
@@ -389,7 +425,7 @@ test('should bulk delete multiple datasets', async ({
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Click bulk delete action
await datasetListPage.clickBulkAction('Delete');
await datasetListPage.clickBulkAction('delete');
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
@@ -404,13 +440,17 @@ test('should bulk delete multiple datasets', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both datasets are removed from list
await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
// Verify both datasets are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
await expect(datasetListPage.getDatasetRow(dataset1.name)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(dataset2.name)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
// Verify via API that datasets no longer exist (404)
await expectDeleted(page, ENDPOINTS.DATASET, dataset1.id, {
@@ -426,14 +466,15 @@ test('should bulk delete multiple datasets', async ({
// this prevents race conditions when parallel workers import the same dataset.
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('import dataset', () => {
test.describe.configure({ mode: 'serial' });
// `timeout` on describe.configure also bounds fixture setup, so the
// `datasetListPage` navigation gets the SLOW_TEST budget too —
// inline `test.setTimeout()` only applies once the test body runs.
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
test('should import a dataset from a zip file', async ({
page,
datasetListPage,
testAssets,
}) => {
test.setTimeout(60_000);
// Create a dataset, export it via API, then delete it, then reimport via UI
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
@@ -455,10 +496,12 @@ test.describe('import dataset', () => {
label: `Dataset ${datasetId}`,
});
// Refresh to confirm dataset is no longer in the list
// Refresh to confirm dataset is no longer in the list (deleted rows are removed from the DOM, so assert count rather than visibility)
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
timeout: TIMEOUT.API_RESPONSE,
});
// Click the import button
await datasetListPage.clickImportButton();
@@ -485,7 +528,7 @@ test.describe('import dataset', () => {
// First response may be 409/422 indicating overwrite is required
const overwriteInput = importModal.getOverwriteInput();
await overwriteInput
.waitFor({ state: 'visible', timeout: 3000 })
.waitFor({ state: 'visible', timeout: TIMEOUT.CONFIRM_DIALOG })
.catch(error => {
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
@@ -507,16 +550,21 @@ test.describe('import dataset', () => {
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
await expect(toast.getSuccess()).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
// Refresh to see the imported dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset appears in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-imported dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Track for cleanup: the dataset import API returns {"message": "OK"}
// with no ID, so look up the reimported dataset by name.

View File

@@ -65,9 +65,12 @@ export const TIMEOUT = {
UI_TRANSITION: 5000, // 5s ceiling for Ant Design animations (~300-500ms actual)
/**
* SQL query execution (query → backend processing → results)
* SQL query execution (query → backend processing → results).
* 30s matches Playwright's default test timeout — cold-start CI on the
* /app/prefix variant has been observed running trivial SELECTs in
* ~25s before results render, which exceeded the previous 15s budget.
*/
QUERY_EXECUTION: 15000, // 15s for SQL queries that may take longer than default expect timeout
QUERY_EXECUTION: 30000, // 30s for SQL queries
/**
* Extended test timeout for multi-step tests (page load + query execution + assertions).

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;
},

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

@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.4.5",
"dompurify": "^3.4.7",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
@@ -42,7 +42,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"react": "^18.2.0"
}
}

View File

@@ -275,29 +275,29 @@ export function wrapTooltip(chart) {
});
}
// 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 rawTitle =
d[layer.titleColumn] && d[layer.titleColumn].length > 0
? `${d[layer.titleColumn]} - ${layer.name}`
: layer.name;
const rawBody = Array.isArray(layer.descriptionColumns)
? layer.descriptionColumns.map(c => d[c])
: Object.values(d);
return dompurify.sanitize(
`<div><strong>${rawTitle}</strong></div><br/><div>${rawBody.join(
', ',
)}</div>`,
);
});
.html(d => (d ? generateAnnotationTooltipContent(layer, d) : ''));
}
export function getMaxLabelSize(svg, axisClass) {

View File

@@ -24,6 +24,7 @@ import {
import {
computeYDomain,
generateAnnotationTooltipContent,
generateBubbleTooltipContent,
generateMultiLineTooltipContent,
getTimeOrNumberFormatter,
@@ -125,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');
@@ -276,4 +313,46 @@ describe('nvd3/utils', () => {
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

@@ -165,6 +165,26 @@ function escapeSQLString(value: string): string {
return value.replace(/'/g, "''");
}
/**
* Coerce a range-filter bound to a finite number, or null if it is not a valid
* numeric value. Unlike a bare Number() call, empty and whitespace-only strings
* are rejected (Number('') === 0), so they never get interpolated into SQL.
* @param value - Raw bound value from the AG Grid filter model
* @returns The finite number, or null if the value is not numeric
*/
function toFiniteNumber(value: FilterValue | undefined): number | null {
// Number(null) and Number('') both coerce to 0 and pass Number.isFinite,
// so reject nullish and empty/whitespace-only strings before coercing.
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string' && value.trim() === '') {
return null;
}
const coerced = Number(value);
return Number.isFinite(coerced) ? coerced : null;
}
// Maximum column name length - conservative upper bound that exceeds all common
// database identifier limits (MySQL: 64, PostgreSQL: 63, SQL Server: 128, Oracle: 128)
const MAX_COLUMN_NAME_LENGTH = 255;
@@ -378,8 +398,17 @@ function simpleFilterToWhereClause(
return '';
}
if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) {
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`;
// Handle IN_RANGE unconditionally so a missing/cleared upper bound can never
// fall through to the generic clause below and emit an invalid single-operand
// BETWEEN. Range bounds are interpolated into the clause without quoting, so
// both ends must coerce to finite numbers; otherwise the clause is dropped.
if (type === FILTER_OPERATORS.IN_RANGE) {
const lowerBound = toFiniteNumber(value);
const upperBound = toFiniteNumber(filterTo);
if (lowerBound === null || upperBound === null) {
return '';
}
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${lowerBound} AND ${upperBound}`;
}
const formattedValue = formatValueForOperator(type, value!);

View File

@@ -284,6 +284,67 @@ describe('agGridFilterConverter', () => {
val: 18,
});
});
test('should emit a numeric BETWEEN clause for a metric range filter', () => {
const filterModel: AgGridFilterModel = {
revenue: {
filterType: 'number',
type: 'inRange',
filter: 10,
filterTo: 20,
},
};
// revenue is a metric, so the range filter renders as a HAVING clause
const result = convertAgGridFiltersToSQL(filterModel, ['revenue']);
expect(result.havingClause).toContain('BETWEEN 10 AND 20');
});
test('should drop a metric range filter whose bounds are not numeric', () => {
const filterModel = {
revenue: {
filterType: 'number',
type: 'inRange',
filter: '0',
filterTo: '100 OR 1=1',
},
} as unknown as AgGridFilterModel;
const result = convertAgGridFiltersToSQL(filterModel, ['revenue']);
// a non-numeric bound must never be interpolated into the clause
expect(result.havingClause).toBeUndefined();
const emptyBoundFilterModel = {
revenue: {
filterType: 'number',
type: 'inRange',
filter: '0',
filterTo: '',
},
} as unknown as AgGridFilterModel;
const result2 = convertAgGridFiltersToSQL(emptyBoundFilterModel, [
'revenue',
]);
expect(result2.havingClause).toBeUndefined();
// A missing upper bound must drop the clause rather than fall through
// to a generic single-operand BETWEEN.
const missingBoundFilterModel = {
revenue: {
filterType: 'number',
type: 'inRange',
filter: '0',
},
} as unknown as AgGridFilterModel;
const result3 = convertAgGridFiltersToSQL(missingBoundFilterModel, [
'revenue',
]);
expect(result3.havingClause).toBeUndefined();
});
});
describe('Null/blank filters', () => {

View File

@@ -35,7 +35,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"echarts": "*",
"memoize-one": "*",
"react": "^18.2.0"

View File

@@ -1016,8 +1016,12 @@ export default function transformProps(
trigger: richTooltip ? 'axis' : 'item',
formatter: (params: any) => {
const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1];
// For axis tooltips, prefer axisValue/axisValueLabel which contains the full label
// even when the axis label is visually truncated
const xValue: number = richTooltip
? params[0].value[xIndex]
? (params[0].axisValue ??
params[0].axisValueLabel ??
params[0].value[xIndex])
: params.value[xIndex];
const forecastValue: CallbackDataParams[] = richTooltip
? params

View File

@@ -1657,3 +1657,100 @@ test('should assign distinct dash patterns for multiple time offsets consistentl
// must be different patterns
expect(symbol1).not.toEqual(symbol2);
});
describe('Tooltip with long labels', () => {
test('should use axisValue for tooltip when available (richTooltip)', () => {
const longLabelData: ChartDataResponseResult[] = [
createTestQueryData([
{
'This is a very long category name that would normally be truncated': 100,
__timestamp: 599616000000,
},
{
'Another extremely long category name for testing purposes': 200,
__timestamp: 599916000000,
},
]),
];
const chartProps = createTestChartProps({
formData: {
richTooltip: true,
},
queriesData: longLabelData,
});
const transformedProps = transformProps(chartProps);
// Get the tooltip formatter function
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
.formatter;
// Simulate params from ECharts with axisValue containing full label
// Use distinct values for axisValue and seriesName to verify axisValue is used
const mockParams = [
{
axisValue:
'This is a very long category name that would normally be truncated',
value: [599616000000, 100],
seriesName: 'Some Series Name',
},
];
// Call the formatter and check it uses the full label from axisValue
const result = tooltipFormatter(mockParams);
expect(result).toContain(
'This is a very long category name that would normally be truncated',
);
});
test('should fallback to value when axisValue is not available', () => {
const chartProps = createTestChartProps({
formData: {
richTooltip: true,
},
});
const transformedProps = transformProps(chartProps);
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
.formatter;
// Simulate params without axisValue
const mockParams = [
{
value: [599616000000, 1],
seriesName: 'San Francisco',
},
];
// Should fall back to the x-value (value[xIndex]) and render it in the title
const result = tooltipFormatter(mockParams);
expect(typeof result).toBe('string');
expect(result).toContain('599616000000');
});
test('should handle item tooltips correctly', () => {
const chartProps = createTestChartProps({
formData: {
richTooltip: false,
},
});
const transformedProps = transformProps(chartProps);
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
.formatter;
// For item tooltips, params is a single object
const mockParams = {
value: [599616000000, 1],
seriesName: 'San Francisco',
};
// The item-tooltip x-value (value[xIndex]) should appear in the title
const result = tooltipFormatter(mockParams);
expect(typeof result).toBe('string');
expect(result).toContain('599616000000');
});
});

View File

@@ -38,7 +38,7 @@
"ace-builds": "^1.4.14",
"handlebars": "^4.7.8",
"lodash": "^4.18.1",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.2.0"

View File

@@ -65,7 +65,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"dayjs": "^1.11.21",
"mapbox-gl": ">=1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"

View File

@@ -23,6 +23,10 @@ import React from 'react';
import { configure as configureTestingLibrary } from '@testing-library/react';
import { matchers } from '@emotion/jest';
if (typeof Intl.DurationFormat === 'undefined') {
require('@formatjs/intl-durationformat/polyfill.js');
}
configureTestingLibrary({
testIdAttribute: 'data-test',
});

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 { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import PluginFilterDynamicGroupBy from './DynamicGroupByPlugin';
import transformProps from './transformProps';
import { PluginFilterGroupByProps } from './types';
const baseProps = {
width: 220,
height: 20,
hooks: {},
filterState: { value: [] },
queriesData: [
{
data: [
{ column_name: 'banana' },
{ column_name: 'apple' },
{ column_name: 'cherry' },
],
},
],
formData: {
datasource: '1__table',
vizType: 'filter_groupby',
nativeFilterId: 'test-filter',
defaultValue: [],
inputRef: { current: null },
},
};
const renderPlugin = (sortAscending?: boolean) => {
const chartProps = new ChartProps({
...baseProps,
formData: { ...baseProps.formData, sortAscending },
theme: supersetTheme,
});
return render(
<PluginFilterDynamicGroupBy
{...(transformProps(chartProps) as unknown as PluginFilterGroupByProps)}
/>,
);
};
const getOpenedOptionOrder = async () => {
userEvent.click(screen.getAllByRole('combobox')[0]);
const options = await screen.findAllByRole('option');
return options.map(option => option.textContent);
};
test('sorts display control values A-Z when sortAscending is true', async () => {
renderPlugin(true);
expect(await getOpenedOptionOrder()).toEqual(['apple', 'banana', 'cherry']);
});
test('sorts display control values Z-A when sortAscending is false', async () => {
renderPlugin(false);
expect(await getOpenedOptionOrder()).toEqual(['cherry', 'banana', 'apple']);
});
test('preserves source order when sorting is disabled', async () => {
renderPlugin(undefined);
expect(await getOpenedOptionOrder()).toEqual(['banana', 'apple', 'cherry']);
});

View File

@@ -18,13 +18,15 @@
*/
import { t, tn } from '@apache-superset/core/translation';
import { ensureIsArray, ExtraFormData } from '@superset-ui/core';
import { useEffect, useState, useMemo } from 'react';
import { useCallback, useEffect, useState, useMemo } from 'react';
import {
FormItem,
type FormItemProps,
LabeledValue,
Select,
type SelectValue,
} from '@superset-ui/core/components';
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
import { FilterPluginStyle, StatusMessage } from '../common';
import { PluginFilterGroupByProps, ColumnOption, ColumnData } from './types';
@@ -116,6 +118,20 @@ export default function PluginFilterDynamicGroupBy(
[data],
);
const sortComparator = useCallback(
(a: LabeledValue, b: LabeledValue) => {
if (formData.sortAscending === undefined) {
return 0;
}
const labelComparator = propertyComparator('label');
if (formData.sortAscending) {
return labelComparator(a, b);
}
return labelComparator(b, a);
},
[formData.sortAscending],
);
return (
<FilterPluginStyle height={height} width={width}>
<FormItem validateStatus={filterState.validateStatus} {...formItemData}>
@@ -132,6 +148,7 @@ export default function PluginFilterDynamicGroupBy(
ref={inputRef}
options={options}
onOpenChange={setFilterActive}
sortComparator={sortComparator}
/>
</div>
</FormItem>

View File

@@ -76,7 +76,6 @@ export const DEFAULT_FORM_DATA: PluginFilterGroupByCustomizeProps = {
dataset: null,
column: null,
sortFilter: false,
sortAscending: true,
canSelectMultiple: true,
defaultValue: null,
};

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ErrorInfo, PureComponent } from 'react';
import { logging } from '@apache-superset/core/utils';
import { ErrorInfo, useCallback, useEffect, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import {
ensureIsArray,
FeatureFlag,
@@ -60,7 +60,7 @@ export interface ChartProps {
sharedLabelColors?: string;
width: number;
height: number;
setControlValue: (name: string, value: unknown) => void;
setControlValue?: (name: string, value: unknown) => void;
timeout?: number;
vizType: string;
triggerRender?: boolean;
@@ -69,7 +69,7 @@ export interface ChartProps {
chartAlert?: string;
chartStatus?: ChartStatus;
chartStackTrace?: string;
queriesResponse: ChartState['queriesResponse'];
queriesResponse?: ChartState['queriesResponse'];
latestQueryFormData?: ChartState['latestQueryFormData'];
triggerQuery?: boolean;
chartIsStale?: boolean;
@@ -126,19 +126,6 @@ const NONEXISTENT_DATASET = t(
'The dataset associated with this chart no longer exists',
);
const defaultProps: Partial<ChartProps> = {
addFilter: () => BLANK,
onFilterMenuOpen: () => BLANK,
onFilterMenuClose: () => BLANK,
initialValues: BLANK,
setControlValue: () => BLANK,
triggerRender: false,
dashboardId: undefined,
chartStackTrace: undefined,
force: false,
isInView: true,
};
const Styles = styled.div<{ height: number; width?: number }>`
min-height: ${p => p.height}px;
position: relative;
@@ -186,252 +173,321 @@ const MessageSpan = styled.span`
color: ${({ theme }) => theme.colorText};
`;
class Chart extends PureComponent<ChartProps, {}> {
static defaultProps = defaultProps;
function Chart({
addFilter = () => BLANK,
onFilterMenuOpen = () => BLANK,
onFilterMenuClose = () => BLANK,
initialValues = BLANK,
setControlValue = () => BLANK,
triggerRender = false,
dashboardId,
chartStackTrace,
force = false,
isInView = true,
...restProps
}: ChartProps): JSX.Element {
const {
actions,
chartId,
datasource,
formData,
timeout,
ownState,
chartAlert,
chartStatus,
queriesResponse = [],
errorMessage,
chartIsStale,
width,
height,
datasetsStatus,
onQuery,
annotationData,
vizType,
latestQueryFormData,
triggerQuery,
postTransformProps,
emitCrossFilters,
onChartStateChange,
suppressLoadingSpinner,
filterState,
} = restProps;
renderStartTime: number;
const renderStartTimeRef = useRef<number>(Logger.getTimestamp());
// Update on each render to accurately track render duration
renderStartTimeRef.current = Logger.getTimestamp();
constructor(props: ChartProps) {
super(props);
this.renderStartTime = Logger.getTimestamp();
this.handleRenderContainerFailure =
this.handleRenderContainerFailure.bind(this);
}
componentDidMount() {
if (this.props.triggerQuery) {
this.runQuery();
}
}
componentDidUpdate() {
if (this.props.triggerQuery) {
this.runQuery();
}
}
shouldRenderChart() {
return (
this.props.isInView ||
const shouldRenderChart = useCallback(
() =>
isInView ||
!isFeatureEnabled(FeatureFlag.DashboardVirtualization) ||
isCurrentUserBot()
);
}
isCurrentUserBot(),
[isInView],
);
runQuery() {
const runQuery = useCallback(() => {
if (
isFeatureEnabled(FeatureFlag.DashboardVirtualizationDeferData) &&
!this.shouldRenderChart()
!shouldRenderChart()
) {
return;
}
// Create chart with POST request
this.props.actions.postChartFormData(
this.props.formData,
Boolean(this.props.force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
this.props.timeout,
this.props.chartId,
this.props.dashboardId,
this.props.ownState,
);
}
handleRenderContainerFailure(error: Error, info: ErrorInfo) {
const { actions, chartId } = this.props;
logging.warn(error);
actions.chartRenderingFailed(
error.toString(),
actions.postChartFormData(
formData,
Boolean(force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
timeout,
chartId,
info?.componentStack ?? null,
);
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
has_err: true,
error_details: error.toString(),
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
renderErrorMessage(queryResponse: ChartErrorType) {
const {
chartId,
chartAlert,
chartStackTrace,
datasource,
dashboardId,
height,
datasetsStatus,
} = this.props;
const error = queryResponse?.errors?.[0];
const message = chartAlert || queryResponse?.message;
ownState,
);
}, [
actions,
chartId,
dashboardId,
formData,
force,
ownState,
shouldRenderChart,
timeout,
]);
// if datasource is still loading, don't render JS errors
// but always show backend API errors (which have an errors array)
// so users can see real issues like auth failures
if (
!error &&
chartAlert !== undefined &&
chartAlert !== NONEXISTENT_DATASET &&
datasource === PLACEHOLDER_DATASOURCE &&
datasetsStatus !== ResourceStatus.Error
) {
return (
<Styles
key={chartId}
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
>
<Loading
size={this.props.dashboardId ? 's' : 'm'}
muted={!!this.props.dashboardId}
/>
</Styles>
const handleRenderContainerFailure = useCallback(
(error: Error, info: ErrorInfo) => {
logging.warn(error);
actions.chartRenderingFailed(
error.toString(),
chartId,
info?.componentStack ?? null,
);
}
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
has_err: true,
error_details: error.toString(),
start_offset: renderStartTimeRef.current,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - renderStartTimeRef.current,
});
},
[actions, chartId],
);
// componentDidMount and componentDidUpdate combined
useEffect(() => {
if (triggerQuery) {
runQuery();
}
}, [triggerQuery, runQuery]);
const renderErrorMessage = useCallback(
(queryResponse: ChartErrorType) => {
const error = queryResponse?.errors?.[0];
const message = chartAlert || queryResponse?.message;
// if datasource is still loading, don't render JS errors
// but always show backend API errors (which have an errors array)
// so users can see real issues like auth failures
if (
!error &&
chartAlert !== undefined &&
chartAlert !== NONEXISTENT_DATASET &&
datasource === PLACEHOLDER_DATASOURCE &&
datasetsStatus !== ResourceStatus.Error
) {
return (
<Styles
key={chartId}
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
>
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
</Styles>
);
}
return (
<ChartErrorMessage
key={chartId}
chartId={chartId}
error={error}
subtitle={message}
link={queryResponse ? queryResponse.link : undefined}
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
stackTrace={chartStackTrace}
/>
);
},
[
chartAlert,
chartId,
chartStackTrace,
dashboardId,
datasetsStatus,
datasource,
height,
],
);
const renderSpinner = useCallback(
(databaseName: string | undefined) => {
const message = databaseName
? t('Waiting on %s', databaseName)
: t('Waiting on database...');
return (
<LoadingDiv>
<Loading
position="inline-centered"
size={dashboardId ? 's' : 'm'}
muted={!!dashboardId}
/>
<MessageSpan>{message}</MessageSpan>
</LoadingDiv>
);
},
[dashboardId],
);
const renderChartContainer = useCallback(
() => (
<div className="slice_container" data-test="slice-container">
{shouldRenderChart() ? (
<ChartRenderer
annotationData={annotationData}
actions={actions}
chartId={chartId}
datasource={datasource}
initialValues={initialValues}
formData={formData}
height={height}
width={width}
setControlValue={setControlValue}
vizType={vizType}
triggerRender={triggerRender}
chartAlert={chartAlert}
chartStatus={chartStatus}
queriesResponse={queriesResponse}
triggerQuery={triggerQuery}
chartIsStale={chartIsStale}
addFilter={addFilter}
onFilterMenuOpen={onFilterMenuOpen}
onFilterMenuClose={onFilterMenuClose}
ownState={ownState}
postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters}
onChartStateChange={onChartStateChange}
latestQueryFormData={latestQueryFormData}
filterState={filterState}
suppressLoadingSpinner={suppressLoadingSpinner}
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
/>
) : (
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
)}
</div>
),
[
actions,
addFilter,
annotationData,
chartAlert,
chartId,
chartIsStale,
chartStatus,
dashboardId,
datasource,
emitCrossFilters,
filterState,
formData,
height,
initialValues,
latestQueryFormData,
onChartStateChange,
onFilterMenuClose,
onFilterMenuOpen,
ownState,
postTransformProps,
queriesResponse,
setControlValue,
shouldRenderChart,
suppressLoadingSpinner,
triggerQuery,
triggerRender,
vizType,
width,
],
);
const databaseName =
datasource?.parent?.name ??
(datasource?.database?.name as string | undefined);
const isLoading = chartStatus === 'loading';
// Suppress spinner during auto-refresh to avoid visual flicker
const showSpinner = isLoading && !suppressLoadingSpinner;
if (chartStatus === 'failed') {
return (
<ChartErrorMessage
key={chartId}
chartId={chartId}
error={error}
subtitle={message}
link={queryResponse ? queryResponse.link : undefined}
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
stackTrace={chartStackTrace}
<ErrorContainer height={height}>
{queriesResponse?.map(item =>
renderErrorMessage(item as ChartErrorType),
)}
</ErrorContainer>
);
}
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
return (
<EmptyState
size="large"
title={t('Add required control values to preview chart')}
description={getChartRequiredFieldsMissingMessage(true)}
image="chart.svg"
/>
);
}
if (
!isLoading &&
!chartAlert &&
!errorMessage &&
chartIsStale &&
ensureIsArray(queriesResponse).length === 0
) {
return (
<EmptyState
size="large"
title={t('Your chart is ready to go!')}
description={
<span>
{t(
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
)}{' '}
<span role="button" tabIndex={0} onClick={onQuery}>
{t('click here')}
</span>
.
</span>
}
image="chart.svg"
/>
);
}
renderSpinner(databaseName: string | undefined) {
const message = databaseName
? t('Waiting on %s', databaseName)
: t('Waiting on database...');
return (
<LoadingDiv>
<Loading
position="inline-centered"
size={this.props.dashboardId ? 's' : 'm'}
muted={!!this.props.dashboardId}
/>
<MessageSpan>{message}</MessageSpan>
</LoadingDiv>
);
}
renderChartContainer() {
return (
<div className="slice_container" data-test="slice-container">
{this.shouldRenderChart() ? (
<ChartRenderer
{...this.props}
source={
this.props.dashboardId
? ChartSource.Dashboard
: ChartSource.Explore
}
data-test={this.props.vizType}
/>
) : (
<Loading
size={this.props.dashboardId ? 's' : 'm'}
muted={!!this.props.dashboardId}
/>
)}
</div>
);
}
render() {
const {
height,
chartAlert,
chartStatus,
datasource,
errorMessage,
chartIsStale,
queriesResponse = [],
width,
} = this.props;
const databaseName =
datasource?.parent?.name ??
(datasource?.database?.name as string | undefined);
const isLoading = chartStatus === 'loading';
// Suppress spinner during auto-refresh to avoid visual flicker
const showSpinner = isLoading && !this.props.suppressLoadingSpinner;
if (chartStatus === 'failed') {
return (
<ErrorContainer height={height}>
{queriesResponse?.map(item =>
this.renderErrorMessage(item as ChartErrorType),
)}
</ErrorContainer>
);
}
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
return (
<EmptyState
size="large"
title={t('Add required control values to preview chart')}
description={getChartRequiredFieldsMissingMessage(true)}
image="chart.svg"
/>
);
}
if (
!isLoading &&
!chartAlert &&
!errorMessage &&
chartIsStale &&
ensureIsArray(queriesResponse).length === 0
) {
return (
<EmptyState
size="large"
title={t('Your chart is ready to go!')}
description={
<span>
{t(
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
)}{' '}
<span role="button" tabIndex={0} onClick={this.props.onQuery}>
{t('click here')}
</span>
.
</span>
}
image="chart.svg"
/>
);
}
return (
<ErrorBoundary
onError={this.handleRenderContainerFailure}
showMessage={false}
return (
<ErrorBoundary onError={handleRenderContainerFailure} showMessage={false}>
<Styles
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
width={width}
>
<Styles
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
width={width}
>
{showSpinner
? this.renderSpinner(databaseName)
: this.renderChartContainer()}
</Styles>
</ErrorBoundary>
);
}
{showSpinner ? renderSpinner(databaseName) : renderChartContainer()}
</Styles>
</ErrorBoundary>
);
}
export default Chart;

View File

@@ -82,7 +82,7 @@ const mockActions: MockActions = {
) => Dispatch,
};
const requiredProps: Partial<ChartRendererProps> = {
const requiredProps: ChartRendererProps = {
chartId: 1,
datasource: {} as ChartRendererProps['datasource'],
formData: {
@@ -111,17 +111,14 @@ afterAll(() => {
test('should render SuperChart', () => {
const { getByTestId } = render(
<ChartRenderer
{...(requiredProps as ChartRendererProps)}
chartIsStale={false}
/>,
<ChartRenderer {...requiredProps} chartIsStale={false} />,
);
expect(getByTestId('mock-super-chart')).toBeInTheDocument();
});
test('should use latestQueryFormData instead of formData when chartIsStale is true', () => {
const { getByTestId } = render(
<ChartRenderer {...(requiredProps as ChartRendererProps)} chartIsStale />,
<ChartRenderer {...requiredProps} chartIsStale />,
);
expect(getByTestId('mock-super-chart')).toHaveTextContent(
JSON.stringify({
@@ -131,9 +128,7 @@ test('should use latestQueryFormData instead of formData when chartIsStale is tr
});
test('should render chart context menu', () => {
const { getByTestId } = render(
<ChartRenderer {...(requiredProps as ChartRendererProps)} />,
);
const { getByTestId } = render(<ChartRenderer {...requiredProps} />);
expect(getByTestId('mock-chart-context-menu')).toBeInTheDocument();
});
@@ -148,16 +143,13 @@ test('should not render chart context menu if the context menu is suppressed for
}),
);
const { queryByTestId } = render(
<ChartRenderer
{...(requiredProps as ChartRendererProps)}
vizType="chart_without_context_menu"
/>,
<ChartRenderer {...requiredProps} vizType="chart_without_context_menu" />,
);
expect(queryByTestId('mock-chart-context-menu')).not.toBeInTheDocument();
});
test('should detect changes in matrixify properties', () => {
const initialProps: Partial<ChartRendererProps> = {
const initialProps: ChartRendererProps = {
...requiredProps,
formData: {
...requiredProps.formData,
@@ -173,41 +165,34 @@ test('should detect changes in matrixify properties', () => {
chartStatus: 'success',
};
render(<ChartRenderer {...(initialProps as ChartRendererProps)} />);
const { getByTestId } = render(<ChartRenderer {...initialProps} />);
// Since we can't directly test shouldComponentUpdate, we verify the component
// correctly identifies matrixify-related properties by checking the implementation
expect((initialProps.formData as JsonObject).matrixify_mode_rows).toBe(
'metrics',
// Verify matrixify-related formData is forwarded through to the chart
expect(getByTestId('mock-super-chart')).toHaveTextContent(
JSON.stringify(initialProps.formData),
);
expect((initialProps.formData as JsonObject).matrixify_dimension_x).toEqual({
dimension: 'country',
values: ['USA'],
});
});
test('should detect changes in postTransformProps', () => {
const postTransformProps = jest.fn((x: JsonObject) => x);
const initialProps: Partial<ChartRendererProps> = {
const initialProps: ChartRendererProps = {
...requiredProps,
queriesResponse: [{ data: 'initial' } as unknown as JsonObject],
chartStatus: 'success',
};
const { rerender } = render(
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
);
const updatedProps: Partial<ChartRendererProps> = {
const { rerender } = render(<ChartRenderer {...initialProps} />);
const updatedProps: ChartRendererProps = {
...initialProps,
postTransformProps,
};
expect(postTransformProps).toHaveBeenCalledTimes(0);
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
rerender(<ChartRenderer {...updatedProps} />);
expect(postTransformProps).toHaveBeenCalledTimes(1);
});
test('should identify matrixify property changes correctly', () => {
// Test that formData with different matrixify properties triggers updates
const initialProps: Partial<ChartRendererProps> = {
const initialProps: ChartRendererProps = {
...requiredProps,
formData: {
datasource: '',
@@ -221,16 +206,14 @@ test('should identify matrixify property changes correctly', () => {
chartStatus: 'success',
};
const { rerender, getByTestId } = render(
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
);
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
expect(getByTestId('mock-super-chart')).toHaveTextContent(
JSON.stringify(initialProps.formData),
);
// Update with changed matrixify_dimension_x values
const updatedProps: Partial<ChartRendererProps> = {
const updatedProps: ChartRendererProps = {
...initialProps,
formData: {
datasource: '',
@@ -245,7 +228,7 @@ test('should identify matrixify property changes correctly', () => {
},
};
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
rerender(<ChartRenderer {...updatedProps} />);
// Verify the component re-rendered with new props
expect(getByTestId('mock-super-chart')).toHaveTextContent(
@@ -254,7 +237,7 @@ test('should identify matrixify property changes correctly', () => {
});
test('should handle matrixify-related form data changes', () => {
const initialProps: Partial<ChartRendererProps> = {
const initialProps: ChartRendererProps = {
...requiredProps,
formData: {
datasource: '',
@@ -265,16 +248,14 @@ test('should handle matrixify-related form data changes', () => {
chartStatus: 'success',
};
const { rerender, getByTestId } = render(
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
);
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
expect(getByTestId('mock-super-chart')).toHaveTextContent(
JSON.stringify(initialProps.formData),
);
// Enable matrixify
const updatedProps: Partial<ChartRendererProps> = {
const updatedProps: ChartRendererProps = {
...initialProps,
formData: {
datasource: '',
@@ -285,7 +266,7 @@ test('should handle matrixify-related form data changes', () => {
},
};
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
rerender(<ChartRenderer {...updatedProps} />);
// Verify the component re-rendered with matrixify enabled
expect(getByTestId('mock-super-chart')).toHaveTextContent(
@@ -294,7 +275,7 @@ test('should handle matrixify-related form data changes', () => {
});
test('should detect matrixify property addition', () => {
const initialProps: Partial<ChartRendererProps> = {
const initialProps: ChartRendererProps = {
...requiredProps,
formData: {
datasource: '',
@@ -307,16 +288,14 @@ test('should detect matrixify property addition', () => {
chartStatus: 'success',
};
const { rerender, getByTestId } = render(
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
);
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
expect(getByTestId('mock-super-chart')).toHaveTextContent(
JSON.stringify(initialProps.formData),
);
// Add matrixify_dimension_x
const updatedProps: Partial<ChartRendererProps> = {
const updatedProps: ChartRendererProps = {
...initialProps,
formData: {
datasource: '',
@@ -327,7 +306,7 @@ test('should detect matrixify property addition', () => {
},
};
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
rerender(<ChartRenderer {...updatedProps} />);
// Verify the component re-rendered with the new property
expect(getByTestId('mock-super-chart')).toHaveTextContent(
@@ -336,7 +315,7 @@ test('should detect matrixify property addition', () => {
});
test('should detect nested matrixify property changes', () => {
const initialProps: Partial<ChartRendererProps> = {
const initialProps: ChartRendererProps = {
...requiredProps,
formData: {
datasource: '',
@@ -353,16 +332,14 @@ test('should detect nested matrixify property changes', () => {
chartStatus: 'success',
};
const { rerender, getByTestId } = render(
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
);
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
expect(getByTestId('mock-super-chart')).toHaveTextContent(
JSON.stringify(initialProps.formData),
);
// Change nested topN value
const updatedProps: Partial<ChartRendererProps> = {
const updatedProps: ChartRendererProps = {
...initialProps,
formData: {
datasource: '',
@@ -377,7 +354,7 @@ test('should detect nested matrixify property changes', () => {
},
};
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
rerender(<ChartRenderer {...updatedProps} />);
// Verify the component re-rendered with the nested change
expect(getByTestId('mock-super-chart')).toHaveTextContent(

View File

@@ -16,8 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { snakeCase, isEqual, cloneDeep } from 'lodash';
import { createRef, Component, RefObject, MouseEvent, ReactNode } from 'react';
import { snakeCase, cloneDeep } from 'lodash';
import {
useCallback,
useEffect,
useState,
useRef,
useMemo,
MouseEvent,
ReactNode,
memo,
} from 'react';
import {
SuperChart,
Behavior,
@@ -28,6 +37,7 @@ import {
QueryFormData,
AnnotationData,
DataMask,
FilterState,
QueryData,
JsonObject,
LatestQueryFormData,
@@ -37,6 +47,7 @@ import {
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { t } from '@apache-superset/core/translation';
import { useTheme } from '@apache-superset/core/theme';
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
import { EmptyState } from '@superset-ui/core/components';
import { ChartSource } from 'src/types/ChartSource';
@@ -91,12 +102,6 @@ interface OwnState {
[key: string]: unknown;
}
// Types for filter state
interface FilterState {
value?: FilterValue[];
[key: string]: unknown;
}
// Props interface
export interface ChartRendererProps {
annotationData?: AnnotationData;
@@ -124,7 +129,6 @@ export interface ChartRendererProps {
merge?: boolean,
refresh?: boolean,
) => void;
setDataMask?: (dataMask: DataMask) => void;
onFilterMenuOpen?: (chartId: number, column: string) => void;
onFilterMenuClose?: (chartId: number, column: string) => void;
ownState?: OwnState;
@@ -137,14 +141,6 @@ export interface ChartRendererProps {
suppressLoadingSpinner?: boolean;
}
// State interface
interface ChartRendererState {
showContextMenu: boolean;
inContextMenu: boolean;
legendState: LegendState | undefined;
legendIndex: number;
}
// Hooks interface
interface ChartHooks {
onAddFilter: (
@@ -175,402 +171,376 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220;
const behaviors = [Behavior.InteractiveChart];
const defaultProps: Partial<ChartRendererProps> = {
addFilter: () => BLANK,
onFilterMenuOpen: () => BLANK,
onFilterMenuClose: () => BLANK,
initialValues: BLANK,
setControlValue: () => {},
triggerRender: false,
};
interface ChartRendererState {
inContextMenu: boolean;
legendState: LegendState | undefined;
legendIndex: number;
}
class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
static defaultProps = defaultProps;
function ChartRendererComponent({
addFilter = () => BLANK,
onFilterMenuOpen = () => BLANK,
onFilterMenuClose = () => BLANK,
initialValues = BLANK,
setControlValue = () => {},
triggerRender = false,
...restProps
}: ChartRendererProps): JSX.Element | null {
const {
annotationData,
actions,
chartId,
datasource,
formData,
latestQueryFormData,
height,
width,
vizType: propVizType,
chartAlert,
chartStatus,
queriesResponse,
chartIsStale,
ownState,
filterState,
postTransformProps,
source,
emitCrossFilters,
onChartStateChange,
} = restProps;
private hasQueryResponseChange: boolean;
const theme = useTheme();
private contextMenuRef: RefObject<ChartContextMenuRef>;
const suppressContextMenu = getChartMetadataRegistry().get(
formData.viz_type ?? propVizType,
)?.suppressContextMenu;
private hooks: ChartHooks;
// Derived from props/feature-flags: must NOT live in state, otherwise a
// `source` or viz-type change on the same mounted instance would leave
// it stale. (Pre-refactor this was a class-instance field recomputed on
// every render — preserve that semantic by using a memo here.)
const showContextMenu = useMemo(
() =>
source === ChartSource.Dashboard &&
!suppressContextMenu &&
isFeatureEnabled(FeatureFlag.DrillToDetail),
[source, suppressContextMenu],
);
private mutableQueriesResponse: QueryData[] | null | undefined;
const [state, setState] = useState<ChartRendererState>({
inContextMenu: false,
legendState: undefined,
legendIndex: 0,
});
private renderStartTime: number;
const hasQueryResponseChangeRef = useRef(false);
const renderStartTimeRef = useRef(0);
const contextMenuRef = useRef<ChartContextMenuRef>(null);
constructor(props: ChartRendererProps) {
super(props);
const suppressContextMenu = getChartMetadataRegistry().get(
props.formData.viz_type ?? props.vizType,
)?.suppressContextMenu;
this.state = {
showContextMenu:
props.source === ChartSource.Dashboard &&
!suppressContextMenu &&
isFeatureEnabled(FeatureFlag.DrillToDetail),
inContextMenu: false,
legendState: undefined,
legendIndex: 0,
};
this.hasQueryResponseChange = false;
this.renderStartTime = 0;
// Results are "ready" when we have a non-error queriesResponse and the
// chartStatus reflects it. This mirrors the gating logic from the former
// shouldComponentUpdate implementation.
const resultsReady =
queriesResponse &&
['success', 'rendered'].indexOf(chartStatus as string) > -1 &&
!queriesResponse?.[0]?.error;
this.contextMenuRef = createRef<ChartContextMenuRef>();
this.handleAddFilter = this.handleAddFilter.bind(this);
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
this.handleRenderFailure = this.handleRenderFailure.bind(this);
this.handleSetControlValue = this.handleSetControlValue.bind(this);
this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
this.handleLegendStateChanged = this.handleLegendStateChanged.bind(this);
this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
this.handleLegendScroll = this.handleLegendScroll.bind(this);
this.hooks = {
onAddFilter: this.handleAddFilter,
onContextMenu: this.state.showContextMenu
? this.handleOnContextMenu
: undefined,
onError: this.handleRenderFailure,
setControlValue: this.handleSetControlValue,
onFilterMenuOpen: this.props.onFilterMenuOpen,
onFilterMenuClose: this.props.onFilterMenuClose,
onLegendStateChanged: this.handleLegendStateChanged,
setDataMask: (dataMask: DataMask) => {
this.props.actions?.updateDataMask?.(this.props.chartId, dataMask);
},
onLegendScroll: this.handleLegendScroll,
onChartStateChange: this.props.onChartStateChange,
};
// TODO: queriesResponse comes from Redux store but it's being edited by
// the plugins, hence we need to clone it to avoid state mutation
// until we change the reducers to use Redux Toolkit with Immer
this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
// Track whether queriesResponse changed since the previous render so that
// handleRenderSuccess / handleRenderFailure know whether to log render time.
// Updating a ref during render is safe when the value doesn't affect the
// render output (here it's read asynchronously from SuperChart callbacks).
const prevQueriesResponseRef = useRef<QueryData[] | null | undefined>(
queriesResponse,
);
if (resultsReady) {
hasQueryResponseChangeRef.current =
queriesResponse !== prevQueriesResponseRef.current;
}
useEffect(() => {
prevQueriesResponseRef.current = queriesResponse;
}, [queriesResponse]);
shouldComponentUpdate(
nextProps: ChartRendererProps,
nextState: ChartRendererState,
): boolean {
const resultsReady =
nextProps.queriesResponse &&
['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
!nextProps.queriesResponse?.[0]?.error;
// Clone queriesResponse to protect against plugin mutation of Redux state.
// Gate on `resultsReady` so the deep clone doesn't run for every
// queriesResponse identity change during loading/idle (only when results
// are actually about to render). Matches the pre-refactor gating.
// TODO: remove once reducers use Redux Toolkit with Immer.
const mutableQueriesResponse = useMemo(
() => (resultsReady ? cloneDeep(queriesResponse) : undefined),
[queriesResponse, resultsReady],
);
if (resultsReady) {
if (!isEqual(this.state, nextState)) {
return true;
}
this.hasQueryResponseChange =
nextProps.queriesResponse !== this.props.queriesResponse;
// Handler functions
const handleAddFilter = useCallback(
(col: string, vals: FilterValue[], merge = true, refresh = true): void => {
addFilter?.(col, vals, merge, refresh);
},
[addFilter],
);
if (this.hasQueryResponseChange) {
this.mutableQueriesResponse = cloneDeep(nextProps.queriesResponse);
}
// Check if any matrixify-related properties have changed
const hasMatrixifyChanges = (): boolean => {
const nextFormData = nextProps.formData as JsonObject;
const currentFormData = this.props.formData as JsonObject;
const isMatrixifyEnabled =
nextFormData.matrixify_enable === true &&
((nextFormData.matrixify_mode_rows !== undefined &&
nextFormData.matrixify_mode_rows !== 'disabled') ||
(nextFormData.matrixify_mode_columns !== undefined &&
nextFormData.matrixify_mode_columns !== 'disabled'));
if (!isMatrixifyEnabled) return false;
// Check all matrixify-related properties
const matrixifyKeys = Object.keys(nextFormData).filter(key =>
key.startsWith('matrixify_'),
);
return matrixifyKeys.some(
key => !isEqual(nextFormData[key], currentFormData[key]),
);
};
const nextFormData = nextProps.formData as JsonObject;
const currentFormData = this.props.formData as JsonObject;
return (
this.hasQueryResponseChange ||
!isEqual(nextProps.datasource, this.props.datasource) ||
nextProps.annotationData !== this.props.annotationData ||
nextProps.ownState !== this.props.ownState ||
nextProps.filterState !== this.props.filterState ||
nextProps.height !== this.props.height ||
nextProps.width !== this.props.width ||
nextProps.triggerRender === true ||
nextProps.labelsColor !== this.props.labelsColor ||
nextProps.labelsColorMap !== this.props.labelsColorMap ||
nextFormData.color_scheme !== currentFormData.color_scheme ||
nextFormData.stack !== currentFormData.stack ||
nextFormData.subcategories !== currentFormData.subcategories ||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
nextProps.postTransformProps !== this.props.postTransformProps ||
hasMatrixifyChanges()
);
}
return false;
}
handleAddFilter(
col: string,
vals: FilterValue[],
merge = true,
refresh = true,
): void {
this.props.addFilter?.(col, vals, merge, refresh);
}
handleRenderSuccess(): void {
const { actions, chartStatus, chartId, vizType } = this.props;
const handleRenderSuccess = useCallback((): void => {
if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
actions.chartRenderingSucceeded(chartId);
}
// only log chart render time which is triggered by query results change
// currently we don't log chart re-render time, like window resize etc
if (this.hasQueryResponseChange) {
if (hasQueryResponseChangeRef.current) {
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
viz_type: vizType,
start_offset: this.renderStartTime,
viz_type: propVizType,
start_offset: renderStartTimeRef.current,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
duration: Logger.getTimestamp() - renderStartTimeRef.current,
});
}
}
}, [actions, chartId, chartStatus, propVizType]);
handleRenderFailure(
error: Error,
info: { componentStack: string } | null,
): void {
const { actions, chartId } = this.props;
logging.warn(error);
actions.chartRenderingFailed(
error.toString(),
chartId,
info ? info.componentStack : null,
);
const handleRenderFailure = useCallback(
(error: Error, info: { componentStack: string } | null): void => {
logging.warn(error);
actions.chartRenderingFailed(
error.toString(),
chartId,
info ? info.componentStack : null,
);
// only trigger render log when query is changed
if (this.hasQueryResponseChange) {
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
has_err: true,
error_details: error.toString(),
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
}
// only trigger render log when query is changed
if (hasQueryResponseChangeRef.current) {
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
has_err: true,
error_details: error.toString(),
start_offset: renderStartTimeRef.current,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - renderStartTimeRef.current,
});
}
},
[actions, chartId],
);
handleSetControlValue(name: string, value: unknown): void {
const { setControlValue } = this.props;
if (setControlValue) {
setControlValue(name, value);
}
}
const handleSetControlValue = useCallback(
(name: string, value: unknown): void => {
if (setControlValue) {
setControlValue(name, value);
}
},
[setControlValue],
);
handleOnContextMenu(
offsetX: number,
offsetY: number,
filters?: ContextMenuFilters,
): void {
this.contextMenuRef.current?.open(offsetX, offsetY, filters);
this.setState({ inContextMenu: true });
}
const handleOnContextMenu = useCallback(
(offsetX: number, offsetY: number, filters?: ContextMenuFilters): void => {
contextMenuRef.current?.open(offsetX, offsetY, filters);
setState(prev => ({ ...prev, inContextMenu: true }));
},
[contextMenuRef],
);
handleContextMenuSelected(): void {
this.setState({ inContextMenu: false });
}
const handleContextMenuSelected = useCallback((): void => {
setState(prev => ({ ...prev, inContextMenu: false }));
}, []);
handleContextMenuClosed(): void {
this.setState({ inContextMenu: false });
}
const handleContextMenuClosed = useCallback((): void => {
setState(prev => ({ ...prev, inContextMenu: false }));
}, []);
handleLegendStateChanged(legendState: LegendState): void {
this.setState({ legendState });
}
const handleLegendStateChanged = useCallback(
(legendState: LegendState): void => {
setState(prev => ({ ...prev, legendState }));
},
[],
);
const handleLegendScroll = useCallback((legendIndex: number): void => {
setState(prev => ({ ...prev, legendIndex }));
}, []);
// When viz plugins don't handle `contextmenu` event, fallback handler
// calls `handleOnContextMenu` with no `filters` param.
onContextMenuFallback(event: MouseEvent<HTMLDivElement>): void {
if (!this.state.inContextMenu) {
event.preventDefault();
this.handleOnContextMenu(event.clientX, event.clientY);
}
const onContextMenuFallback = useCallback(
(event: MouseEvent<HTMLDivElement>): void => {
if (!state.inContextMenu) {
event.preventDefault();
handleOnContextMenu(event.clientX, event.clientY);
}
},
[handleOnContextMenu, state.inContextMenu],
);
const setDataMaskCallback = useCallback(
(dataMask: DataMask) => {
actions?.updateDataMask?.(chartId, dataMask);
},
[actions, chartId],
);
// Hooks object - memoized
const hooks = useMemo<ChartHooks>(
() => ({
onAddFilter: handleAddFilter,
onContextMenu: showContextMenu ? handleOnContextMenu : undefined,
onError: handleRenderFailure,
setControlValue: handleSetControlValue,
onFilterMenuOpen,
onFilterMenuClose,
onLegendStateChanged: handleLegendStateChanged,
setDataMask: setDataMaskCallback,
onLegendScroll: handleLegendScroll,
onChartStateChange,
}),
[
handleAddFilter,
handleLegendScroll,
handleLegendStateChanged,
handleOnContextMenu,
handleRenderFailure,
handleSetControlValue,
onChartStateChange,
onFilterMenuClose,
onFilterMenuOpen,
setDataMaskCallback,
showContextMenu,
],
);
const hasAnyErrors = queriesResponse?.some(item => item?.error);
const hasValidPreviousData =
(queriesResponse?.length ?? 0) > 0 && !hasAnyErrors;
if (!!chartAlert || chartStatus === null) {
return null;
}
handleLegendScroll(legendIndex: number): void {
this.setState({ legendIndex });
}
render(): ReactNode {
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
const hasAnyErrors = this.props.queriesResponse?.some(item => item?.error);
const hasValidPreviousData =
(this.props.queriesResponse?.length ?? 0) > 0 && !hasAnyErrors;
if (!!chartAlert || chartStatus === null) {
if (chartStatus === 'loading') {
if (!restProps.suppressLoadingSpinner || !hasValidPreviousData) {
return null;
}
}
if (chartStatus === 'loading') {
if (!this.props.suppressLoadingSpinner || !hasValidPreviousData) {
return null;
}
}
renderStartTimeRef.current = Logger.getTimestamp();
this.renderStartTime = Logger.getTimestamp();
const currentFormData =
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
const vizType = currentFormData.viz_type || propVizType;
const {
width,
height,
datasource,
annotationData,
initialValues,
ownState,
filterState,
chartIsStale,
formData,
latestQueryFormData,
postTransformProps,
} = this.props;
// It's bad practice to use unprefixed `vizType` as classnames for chart
// container. It may cause css conflicts as in the case of legacy table chart.
// When migrating charts, we should gradually add a `superset-chart-` prefix
// to each one of them.
const snakeCaseVizType = snakeCase(vizType);
const chartClassName =
vizType === VizType.Table
? `superset-chart-${snakeCaseVizType}`
: snakeCaseVizType;
const currentFormData =
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
const vizType = currentFormData.viz_type || this.props.vizType;
const webpackHash =
process.env.WEBPACK_MODE === 'development'
? `-${
// eslint-disable-next-line camelcase
typeof __webpack_require__ !== 'undefined' &&
// eslint-disable-next-line camelcase, no-undef
typeof __webpack_require__.h === 'function' &&
// eslint-disable-next-line no-undef, camelcase
__webpack_require__.h()
}`
: '';
// It's bad practice to use unprefixed `vizType` as classnames for chart
// container. It may cause css conflicts as in the case of legacy table chart.
// When migrating charts, we should gradually add a `superset-chart-` prefix
// to each one of them.
const snakeCaseVizType = snakeCase(vizType);
const chartClassName =
vizType === VizType.Table
? `superset-chart-${snakeCaseVizType}`
: snakeCaseVizType;
const webpackHash =
process.env.WEBPACK_MODE === 'development'
? `-${
// eslint-disable-next-line camelcase
typeof __webpack_require__ !== 'undefined' &&
// eslint-disable-next-line camelcase, no-undef
typeof __webpack_require__.h === 'function' &&
// eslint-disable-next-line no-undef, camelcase
__webpack_require__.h()
}`
: '';
let noResultsComponent: ReactNode;
const noResultTitle = t('No results were returned for this query');
const noResultDescription =
this.props.source === ChartSource.Explore
? t(
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
)
: undefined;
const noResultImage = 'chart.svg';
if (
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
) {
noResultsComponent = (
<EmptyState
size="large"
title={noResultTitle}
description={noResultDescription}
image={noResultImage}
/>
);
} else {
noResultsComponent = (
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
);
}
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
const drillToDetailProps = getChartMetadataRegistry()
.get(vizType)
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
? { inContextMenu: this.state.inContextMenu }
: {};
// By pass no result component when server pagination is enabled & the table has:
// - a backend search query, OR
// - non-empty AG Grid filter model
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
const hasAgGridFilters =
ownState?.agGridFilterModel &&
Object.keys(ownState.agGridFilterModel).length > 0;
const currentFormDataExtended = currentFormData as JsonObject;
const bypassNoResult = !(
currentFormDataExtended?.server_pagination &&
(hasSearchText || hasAgGridFilters)
let noResultsComponent: ReactNode;
const noResultTitle = t('No results were returned for this query');
const noResultDescription =
source === ChartSource.Explore
? t(
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
)
: undefined;
const noResultImage = 'chart.svg';
if (
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
) {
noResultsComponent = (
<EmptyState
size="large"
title={noResultTitle}
description={noResultDescription}
image={noResultImage}
/>
);
return (
<>
{this.state.showContextMenu && (
<ChartContextMenu
ref={this.contextMenuRef}
id={chartId}
formData={currentFormData as QueryFormData}
onSelection={this.handleContextMenuSelected}
onClose={this.handleContextMenuClosed}
/>
)}
<div
onContextMenu={
this.state.showContextMenu ? this.onContextMenuFallback : undefined
}
>
<SuperChart
disableErrorBoundary
key={`${chartId}${webpackHash}`}
id={`chart-id-${chartId}`}
className={chartClassName}
chartType={vizType}
width={width}
height={height}
annotationData={annotationData}
datasource={datasource}
initialValues={initialValues}
formData={currentFormData}
ownState={ownState}
filterState={filterState}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hooks={this.hooks as any}
behaviors={behaviors}
queriesData={this.mutableQueriesResponse ?? undefined}
onRenderSuccess={this.handleRenderSuccess}
onRenderFailure={this.handleRenderFailure}
noResults={noResultsComponent}
postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters}
legendState={this.state.legendState}
enableNoResults={bypassNoResult}
legendIndex={this.state.legendIndex}
isRefreshing={
Boolean(this.props.suppressLoadingSpinner) &&
chartStatus === 'loading'
}
{...drillToDetailProps}
/>
</div>
</>
} else {
noResultsComponent = (
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
);
}
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
const drillToDetailProps = getChartMetadataRegistry()
.get(vizType)
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
? { inContextMenu: state.inContextMenu }
: {};
// By pass no result component when server pagination is enabled & the table has:
// - a backend search query, OR
// - non-empty AG Grid filter model
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
const hasAgGridFilters =
ownState?.agGridFilterModel &&
Object.keys(ownState.agGridFilterModel).length > 0;
const currentFormDataExtended = currentFormData as JsonObject;
const bypassNoResult = !(
currentFormDataExtended?.server_pagination &&
(hasSearchText || hasAgGridFilters)
);
return (
<>
{showContextMenu && (
<ChartContextMenu
ref={contextMenuRef}
id={chartId}
formData={currentFormData as QueryFormData}
onSelection={handleContextMenuSelected}
onClose={handleContextMenuClosed}
/>
)}
<div onContextMenu={showContextMenu ? onContextMenuFallback : undefined}>
<SuperChart
disableErrorBoundary
key={`${chartId}${webpackHash}`}
id={`chart-id-${chartId}`}
className={chartClassName}
chartType={vizType}
width={width}
height={height}
theme={theme}
annotationData={annotationData}
datasource={datasource}
initialValues={initialValues}
formData={currentFormData}
ownState={ownState}
filterState={filterState}
hooks={hooks as unknown as Parameters<typeof SuperChart>[0]['hooks']}
behaviors={behaviors}
queriesData={mutableQueriesResponse ?? undefined}
onRenderSuccess={handleRenderSuccess}
onRenderFailure={handleRenderFailure}
noResults={noResultsComponent}
postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters}
legendState={state.legendState}
enableNoResults={bypassNoResult}
legendIndex={state.legendIndex}
isRefreshing={
Boolean(restProps.suppressLoadingSpinner) &&
chartStatus === 'loading'
}
{...drillToDetailProps}
/>
</div>
</>
);
}
const ChartRenderer = memo(ChartRendererComponent);
export default ChartRenderer;

View File

@@ -23,7 +23,7 @@ import {
SuperChart,
ContextMenuFilters,
} from '@superset-ui/core';
import { css } from '@apache-superset/core/theme';
import { css, useTheme } from '@apache-superset/core/theme';
import { Dataset } from '../types';
interface DrillByChartProps {
@@ -45,6 +45,7 @@ export default function DrillByChart({
onContextMenu,
inContextMenu,
}: DrillByChartProps) {
const theme = useTheme();
const hooks = useMemo(() => ({ onContextMenu }), [onContextMenu]);
return (
@@ -67,6 +68,7 @@ export default function DrillByChart({
inContextMenu={inContextMenu}
height="100%"
width="100%"
theme={theme}
/>
</div>
);

View File

@@ -239,7 +239,10 @@ describe('ListView', () => {
});
test('calls fetchData on sort', async () => {
const sortHeader = screen.getAllByTestId('sort-header')[1];
// sort-header[0] is the first data column ('id'); the select-all
// column header carries `data-test="header-toggle-all"` instead
// of `sort-header` (see TableCollection's `header.cell` slot).
const sortHeader = screen.getAllByTestId('sort-header')[0];
await userEvent.click(sortHeader);
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalledWith({

View File

@@ -33,7 +33,6 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
import {
Button,
Tooltip,
Checkbox,
Icons,
EmptyState,
Loading,
@@ -179,21 +178,6 @@ const BulkSelectWrapper = styled(Alert)`
`}
`;
const bulkSelectColumnConfig = {
Cell: ({ row }: any) => (
<Checkbox {...row.getToggleRowSelectedProps()} id={row.id} />
),
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<Checkbox
{...getToggleAllRowsSelectedProps()}
id="header-toggle-all"
data-test="header-toggle-all"
/>
),
id: 'selection',
size: 'sm',
};
const ViewModeContainer = styled.div`
${({ theme }) => `
padding-right: ${theme.sizeUnit * 4}px;
@@ -375,8 +359,6 @@ export function ListView<T extends object = any>({
state: { pageIndex, pageSize, internalFilters, sortBy, viewMode },
query,
} = useListViewState({
bulkSelectColumnConfig,
bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length),
columns,
count,
data,
@@ -527,6 +509,7 @@ export function ListView<T extends object = any>({
{bulkActions.map(action => (
<Button
data-test="bulk-select-action"
data-test-action-key={action.key}
key={action.key}
buttonStyle={action.type}
cta

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState, ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
useFilters,
usePagination,
@@ -192,13 +192,7 @@ interface UseListViewConfig {
count: number;
initialPageSize: number;
initialSort?: SortColumn[];
bulkSelectMode?: boolean;
initialFilters?: Filter[];
bulkSelectColumnConfig?: {
id: string;
Header: (conf: any) => ReactNode;
Cell: (conf: any) => ReactNode;
};
renderCard?: boolean;
defaultViewMode?: ViewModeType;
}
@@ -211,8 +205,6 @@ export function useListViewState({
initialPageSize,
initialFilters = [],
initialSort = [],
bulkSelectMode = false,
bulkSelectColumnConfig,
renderCard = false,
defaultViewMode = 'card',
}: UseListViewConfig) {
@@ -246,13 +238,11 @@ export function useListViewState({
(renderCard ? defaultViewMode : 'table'),
);
const columnsWithSelect = useMemo(() => {
const columnsWithFilter = useMemo(
// add exact filter type so filters with falsy values are not filtered out
const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' }));
return bulkSelectMode
? [bulkSelectColumnConfig, ...columnsWithFilter]
: columnsWithFilter;
}, [bulkSelectMode, columns]);
() => columns.map(f => ({ ...f, filter: 'exact' })),
[columns],
);
const {
getTableProps,
@@ -271,7 +261,7 @@ export function useListViewState({
state: { pageIndex, pageSize, sortBy, filters },
} = useTable(
{
columns: columnsWithSelect,
columns: columnsWithFilter,
data,
disableFilters: true,
disableSortRemove: true,

View File

@@ -30,6 +30,7 @@ jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
getCSRFToken: jest.fn(() => Promise.resolve('mock-csrf-token')),
getGuestToken: jest.fn(() => undefined),
},
}));
@@ -47,9 +48,12 @@ global.URL.revokeObjectURL = jest.fn();
global.fetch = jest.fn();
const { SupersetClient } = jest.requireMock('@superset-ui/core');
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
SupersetClient.getGuestToken.mockReturnValue(undefined);
});
test('useStreamingExport initializes with default progress state', () => {
@@ -238,6 +242,32 @@ const createPrefixTestMockFetch = () =>
},
});
test('chart streaming export includes guest token in form body when configured', async () => {
SupersetClient.getGuestToken.mockReturnValue('guest-token');
const mockFetch = createPrefixTestMockFetch();
global.fetch = mockFetch;
const { result } = renderHook(() => useStreamingExport());
act(() => {
result.current.startExport({
url: '/api/v1/chart/data',
payload: { datasource: '1__table', viz_type: 'table' },
exportType: 'csv',
});
});
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
const request = mockFetch.mock.calls[0][1];
expect(request.body.get('guest_token')).toBe('guest-token');
expect(request.body.get('form_data')).toBe(
JSON.stringify({ datasource: '1__table', viz_type: 'table' }),
);
});
test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => {
const appRoot = '/superset';
applicationRoot.mockReturnValue(appRoot);
@@ -652,6 +682,8 @@ test('completes XLSX export successfully with correct filename', async () => {
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
});
const request = mockFetch.mock.calls[0][1];
expect(request.body.get('guest_token')).toBeNull();
expect(result.current.progress.filename).toBe('report.xlsx');
expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'report.xlsx');
});

View File

@@ -118,6 +118,11 @@ const createFetchRequest = async (
formParams.expected_rows = expectedRows.toString();
}
const guestToken = SupersetClient.getGuestToken();
if (guestToken) {
formParams.guest_token = guestToken;
}
if ('client_id' in payload) {
// SQL Lab export - pass client_id directly
formParams.client_id = String(payload.client_id);

View File

@@ -16,7 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetClient, isFeatureEnabled } from '@superset-ui/core';
import {
JsonResponse,
SupersetClient,
isFeatureEnabled,
} from '@superset-ui/core';
import { waitFor } from 'spec/helpers/testing-library';
import {
@@ -28,9 +32,16 @@ import {
ON_FILTERS_REFRESH,
ON_REFRESH,
ON_REFRESH_SUCCESS,
TOGGLE_FAVE_STAR,
TOGGLE_PUBLISHED,
fetchFaveStar,
saveFaveStar,
savePublished,
} from 'src/dashboard/actions/dashboardState';
import { refreshChart } from 'src/components/Chart/chartAction';
import { UPDATE_COMPONENTS_PARENTS_LIST } from 'src/dashboard/actions/dashboardLayout';
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
import { ToastType } from 'src/components/MessageToasts/types';
import {
DASHBOARD_GRID_ID,
SAVE_TYPE_OVERWRITE,
@@ -400,4 +411,322 @@ describe('dashboardState actions', () => {
expect(dispatchedTypes).not.toContain(ON_REFRESH);
expect(dispatchedTypes).not.toContain(ON_FILTERS_REFRESH);
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('fetchFaveStar race condition', () => {
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches', async () => {
const id = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id,
metadata: { color_scheme: 'supersetColors' },
},
});
getStub.mockRestore();
getStub = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [{ value: true }] },
} as unknown as JsonResponse);
await fetchFaveStar(id)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({
type: TOGGLE_FAVE_STAR,
isStarred: true,
});
});
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
const requestedId = 123;
// User navigated to a different dashboard by the time the response comes back
const { getState, dispatch } = setup({
dashboardInfo: {
id: 456,
metadata: { color_scheme: 'supersetColors' },
},
});
getStub.mockRestore();
getStub = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [{ value: true }] },
} as unknown as JsonResponse);
await fetchFaveStar(requestedId)(dispatch, getState);
expect(dispatch).not.toHaveBeenCalled();
});
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
const id = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id,
metadata: { color_scheme: 'supersetColors' },
},
});
getStub.mockRestore();
getStub = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('network'));
await fetchFaveStar(id)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0][0]).toEqual(
expect.objectContaining({
type: ADD_TOAST,
payload: expect.objectContaining({
toastType: ToastType.Danger,
}),
}),
);
});
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
const requestedId = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id: 456,
metadata: { color_scheme: 'supersetColors' },
},
});
getStub.mockRestore();
getStub = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('network'));
await fetchFaveStar(requestedId)(dispatch, getState);
expect(dispatch).not.toHaveBeenCalled();
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('saveFaveStar race condition', () => {
let deleteStub: jest.SpyInstance;
beforeEach(() => {
deleteStub = jest
.spyOn(SupersetClient, 'delete')
.mockResolvedValue({} as unknown as JsonResponse);
});
afterEach(() => {
deleteStub.mockRestore();
});
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches (starring)', async () => {
const id = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id,
metadata: { color_scheme: 'supersetColors' },
},
});
postStub.mockRestore();
postStub = jest
.spyOn(SupersetClient, 'post')
.mockResolvedValue({} as unknown as JsonResponse);
await saveFaveStar(id, false)(dispatch, getState);
expect(postStub).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({
type: TOGGLE_FAVE_STAR,
isStarred: true,
});
});
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches (unstarring)', async () => {
const id = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id,
metadata: { color_scheme: 'supersetColors' },
},
});
await saveFaveStar(id, true)(dispatch, getState);
expect(deleteStub).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({
type: TOGGLE_FAVE_STAR,
isStarred: false,
});
});
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
const requestedId = 123;
// User navigated to a different dashboard by the time the response comes back
const { getState, dispatch } = setup({
dashboardInfo: {
id: 456,
metadata: { color_scheme: 'supersetColors' },
},
});
postStub.mockRestore();
postStub = jest
.spyOn(SupersetClient, 'post')
.mockResolvedValue({} as unknown as JsonResponse);
await saveFaveStar(requestedId, false)(dispatch, getState);
expect(postStub).toHaveBeenCalledTimes(1);
expect(dispatch).not.toHaveBeenCalled();
});
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
const id = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id,
metadata: { color_scheme: 'supersetColors' },
},
});
postStub.mockRestore();
postStub = jest
.spyOn(SupersetClient, 'post')
.mockRejectedValue(new Error('network'));
await saveFaveStar(id, false)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0][0]).toEqual(
expect.objectContaining({
type: ADD_TOAST,
payload: expect.objectContaining({
toastType: ToastType.Danger,
}),
}),
);
});
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
const requestedId = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id: 456,
metadata: { color_scheme: 'supersetColors' },
},
});
postStub.mockRestore();
postStub = jest
.spyOn(SupersetClient, 'post')
.mockRejectedValue(new Error('network'));
await saveFaveStar(requestedId, false)(dispatch, getState);
expect(dispatch).not.toHaveBeenCalled();
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('savePublished race condition', () => {
test('dispatches success toast and TOGGLE_PUBLISHED when the dashboard ID still matches', async () => {
const id = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id,
metadata: { color_scheme: 'supersetColors' },
},
});
putStub.mockRestore();
putStub = jest
.spyOn(SupersetClient, 'put')
.mockResolvedValue({} as unknown as JsonResponse);
await savePublished(id, true)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch.mock.calls[0][0]).toEqual(
expect.objectContaining({
type: ADD_TOAST,
payload: expect.objectContaining({
toastType: ToastType.Success,
}),
}),
);
expect(dispatch).toHaveBeenCalledWith({
type: TOGGLE_PUBLISHED,
isPublished: true,
});
});
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
const requestedId = 123;
// User navigated to a different dashboard by the time the response comes back
const { getState, dispatch } = setup({
dashboardInfo: {
id: 456,
metadata: { color_scheme: 'supersetColors' },
},
});
putStub.mockRestore();
putStub = jest
.spyOn(SupersetClient, 'put')
.mockResolvedValue({} as unknown as JsonResponse);
await savePublished(requestedId, true)(dispatch, getState);
expect(putStub).toHaveBeenCalledTimes(1);
expect(dispatch).not.toHaveBeenCalled();
});
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
const id = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id,
metadata: { color_scheme: 'supersetColors' },
},
});
putStub.mockRestore();
putStub = jest
.spyOn(SupersetClient, 'put')
.mockRejectedValue(new Error('forbidden'));
await savePublished(id, true)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0][0]).toEqual(
expect.objectContaining({
type: ADD_TOAST,
payload: expect.objectContaining({
toastType: ToastType.Danger,
}),
}),
);
});
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
const requestedId = 123;
const { getState, dispatch } = setup({
dashboardInfo: {
id: 456,
metadata: { color_scheme: 'supersetColors' },
},
});
putStub.mockRestore();
putStub = jest
.spyOn(SupersetClient, 'put')
.mockRejectedValue(new Error('forbidden'));
await savePublished(requestedId, true)(dispatch, getState);
expect(dispatch).not.toHaveBeenCalled();
});
});
});

View File

@@ -160,27 +160,43 @@ export function toggleFaveStar(isStarred: boolean): ToggleFaveStarAction {
}
export function fetchFaveStar(id: number) {
return function fetchFaveStarThunk(dispatch: AppDispatch) {
return function fetchFaveStarThunk(
dispatch: AppDispatch,
getState: GetState,
) {
return SupersetClient.get({
endpoint: `/api/v1/dashboard/favorite_status/?q=${rison.encode([id])}`,
})
.then(({ json }: { json: JsonObject }) => {
dispatch(toggleFaveStar(!!(json?.result as JsonObject[])?.[0]?.value));
// Only update state if this is still the current dashboard
// This prevents stale responses from affecting the UI after navigation
const currentId = getState().dashboardInfo?.id;
if (currentId === id) {
dispatch(
toggleFaveStar(!!(json?.result as JsonObject[])?.[0]?.value),
);
}
})
.catch(() =>
dispatch(
addDangerToast(
t(
'There was an issue fetching the favorite status of this dashboard.',
.catch(() => {
// Only show error if this is still the current dashboard
// This prevents error toasts from appearing for dashboards the user
// has already navigated away from (e.g., deleted dashboards)
const currentId = getState().dashboardInfo?.id;
if (currentId === id) {
dispatch(
addDangerToast(
t(
'There was an issue fetching the favorite status of this dashboard.',
),
),
),
),
);
);
}
});
};
}
export function saveFaveStar(id: number, isStarred: boolean) {
return function saveFaveStarThunk(dispatch: AppDispatch) {
return function saveFaveStarThunk(dispatch: AppDispatch, getState: GetState) {
const endpoint = `/api/v1/dashboard/${id}/favorites/`;
const apiCall = isStarred
? SupersetClient.delete({
@@ -190,13 +206,21 @@ export function saveFaveStar(id: number, isStarred: boolean) {
return apiCall
.then(() => {
dispatch(toggleFaveStar(!isStarred));
// Only update state if this is still the current dashboard
const currentId = getState().dashboardInfo?.id;
if (currentId === id) {
dispatch(toggleFaveStar(!isStarred));
}
})
.catch(() =>
dispatch(
addDangerToast(t('There was an issue favoriting this dashboard.')),
),
);
.catch(() => {
// Only show error if this is still the current dashboard
const currentId = getState().dashboardInfo?.id;
if (currentId === id) {
dispatch(
addDangerToast(t('There was an issue favoriting this dashboard.')),
);
}
});
};
}
@@ -214,8 +238,11 @@ export function togglePublished(isPublished: boolean): TogglePublishedAction {
export function savePublished(
id: number,
isPublished: boolean,
): (dispatch: AppDispatch) => Promise<void> {
return function savePublishedThunk(dispatch: AppDispatch): Promise<void> {
): (dispatch: AppDispatch, getState: GetState) => Promise<void> {
return function savePublishedThunk(
dispatch: AppDispatch,
getState: GetState,
): Promise<void> {
return SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
@@ -224,21 +251,30 @@ export function savePublished(
}),
})
.then(() => {
dispatch(
addSuccessToast(
isPublished
? t('This dashboard is now published')
: t('This dashboard is now hidden'),
),
);
dispatch(togglePublished(isPublished));
// Only update state if this is still the current dashboard
// This prevents stale responses from affecting the UI after navigation
const currentId = getState().dashboardInfo?.id;
if (currentId === id) {
dispatch(
addSuccessToast(
isPublished
? t('This dashboard is now published')
: t('This dashboard is now hidden'),
),
);
dispatch(togglePublished(isPublished));
}
})
.catch(() => {
dispatch(
addDangerToast(
t('You do not have permissions to edit this dashboard.'),
),
);
// Only show error if this is still the current dashboard
const currentId = getState().dashboardInfo?.id;
if (currentId === id) {
dispatch(
addDangerToast(
t('You do not have permissions to edit this dashboard.'),
),
);
}
});
};
}

View File

@@ -0,0 +1,41 @@
/**
* 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 { LabeledValue } from '@superset-ui/core/components';
import { createLabelSortComparator } from './GroupByFilterCard';
const apple: LabeledValue = { value: 'a', label: 'Apple' };
const banana: LabeledValue = { value: 'b', label: 'Banana' };
test('sorts display values A-Z when sortAscending is true', () => {
const compare = createLabelSortComparator(true);
expect(compare(apple, banana)).toBeLessThan(0);
expect(compare(banana, apple)).toBeGreaterThan(0);
});
test('sorts display values Z-A when sortAscending is false', () => {
const compare = createLabelSortComparator(false);
expect(compare(apple, banana)).toBeGreaterThan(0);
expect(compare(banana, apple)).toBeLessThan(0);
});
test('preserves source order when sortAscending is unset', () => {
const compare = createLabelSortComparator(undefined);
expect(compare(apple, banana)).toBe(0);
expect(compare(banana, apple)).toBe(0);
});

View File

@@ -37,12 +37,14 @@ import {
import {
Typography,
Select,
type LabeledValue,
Popover,
Loading,
Icons,
Tooltip,
FormItem,
} from '@superset-ui/core/components';
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
import { setPendingChartCustomization } from 'src/dashboard/actions/chartCustomizationActions';
@@ -210,6 +212,18 @@ const DescriptionTooltip = ({ description }: { description: string }) => (
</ToolTipContainer>
);
// Sort display values by label: ascending when sortAscending is true, descending
// when false, and source order (no sort) when it is unset.
export const createLabelSortComparator =
(sortAscending?: boolean) =>
(a: LabeledValue, b: LabeledValue): number => {
if (sortAscending === undefined) {
return 0;
}
const labelComparator = propertyComparator('label');
return sortAscending ? labelComparator(a, b) : labelComparator(b, a);
};
const GroupByFilterCardContent: FC<{
customizationItem: ChartCustomization;
hidePopover: () => void;
@@ -230,14 +244,6 @@ const GroupByFilterCardContent: FC<{
return t('None');
}, [dataset, datasetName]);
const aggregationDisplay = useMemo(() => {
const sortMetric = customizationItem.controlValues?.sortMetric;
if (sortMetric) {
return sortMetric.toUpperCase();
}
return t('None');
}, [customizationItem.controlValues?.sortMetric]);
return (
<div>
<Row
@@ -271,11 +277,6 @@ const GroupByFilterCardContent: FC<{
{typeof datasetLabel === 'string' ? datasetLabel : t('Dataset')}
</RowValue>
</Row>
<Row>
<RowLabel>{t('Aggregation')}</RowLabel>
<RowValue>{aggregationDisplay}</RowValue>
</Row>
</div>
);
};
@@ -342,6 +343,13 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
const canSelectMultiple =
customizationItem.controlValues?.canSelectMultiple ?? true;
const sortAscending = customizationItem.controlValues?.sortAscending;
const sortComparator = useMemo(
() => createLabelSortComparator(sortAscending),
[sortAscending],
);
const columnDisplayName = useMemo(() => {
if (customizationItem.name) {
return customizationItem.name;
@@ -594,6 +602,7 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
.toLowerCase()
.includes(input.toLowerCase())
}
sortComparator={sortComparator}
getPopupContainer={triggerNode => triggerNode.parentNode}
oneLine={isHorizontalLayout}
className="select-container"
@@ -623,6 +632,7 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
.toLowerCase()
.includes(input.toLowerCase())
}
sortComparator={sortComparator}
loading={loading}
/>
</div>

View File

@@ -1418,7 +1418,7 @@ const FiltersConfigForm = (
}}
/>
</StyledRowFormItem>
{hasMetrics && (
{hasMetrics && !isChartCustomization && (
<StyledRowSubFormItem
expanded={expanded}
name={[

View File

@@ -800,6 +800,139 @@ test('does not auto-create a filter when createNewOnOpen is false', () => {
expect(screen.queryByText(DATASET_REGEX)).not.toBeInTheDocument();
});
test('enables save button and includes updated title when editing an existing divider', async () => {
jest.useFakeTimers();
const nativeFilterDividerConfig = [
{
id: 'NATIVE_FILTER_DIVIDER-1',
type: 'DIVIDER' as const,
title: 'First Edit',
description: '',
scope: { rootPath: ['ROOT'], excluded: [] },
},
];
const state = {
...defaultState(),
dashboardInfo: {
metadata: {
native_filter_configuration: nativeFilterDividerConfig,
},
},
dashboardLayout,
};
const onSave = jest.fn();
defaultRender(state, {
...props,
createNewOnOpen: false,
initialFilterId: 'NATIVE_FILTER_DIVIDER-1',
onSave,
});
// Save button should be disabled when no changes have been made
expect(screen.getByRole('button', { name: SAVE_REGEX })).toBeDisabled();
// Editing the title field should mark the divider modified and enable save
const titleInput = screen.getByRole('textbox', { name: /^title$/i });
await userEvent.clear(titleInput);
await userEvent.type(titleInput, 'Second Edit');
jest.advanceTimersByTime(500);
jest.useRealTimers();
await waitFor(() =>
expect(
screen.getByRole('button', { name: SAVE_REGEX }),
).not.toBeDisabled(),
);
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
filterChanges: expect.objectContaining({
modified: expect.arrayContaining([
expect.objectContaining({
id: 'NATIVE_FILTER_DIVIDER-1',
title: 'Second Edit',
}),
]),
}),
}),
),
);
}, 30000);
test('enables save button and includes updated title when editing an existing chart customization divider', async () => {
jest.useFakeTimers();
const chartCustomizationDividerConfig = [
{
id: 'CHART_CUSTOMIZATION_DIVIDER-1',
type: 'CHART_CUSTOMIZATION_DIVIDER' as const,
title: 'First Edit',
description: '',
},
];
const state = {
...defaultState(),
dashboardInfo: {
metadata: {
chart_customization_config: chartCustomizationDividerConfig,
},
},
dashboardLayout,
};
const onSave = jest.fn();
defaultRender(state, {
...props,
createNewOnOpen: false,
initialFilterId: 'CHART_CUSTOMIZATION_DIVIDER-1',
onSave,
});
// Save button should be disabled when no changes have been made
expect(screen.getByRole('button', { name: SAVE_REGEX })).toBeDisabled();
// Editing the title field should mark the divider modified and enable save
const titleInput = screen.getByRole('textbox', { name: /^title$/i });
await userEvent.clear(titleInput);
await userEvent.type(titleInput, 'Second Edit');
jest.advanceTimersByTime(500);
jest.useRealTimers();
await waitFor(() =>
expect(
screen.getByRole('button', { name: SAVE_REGEX }),
).not.toBeDisabled(),
);
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
customizationChanges: expect.objectContaining({
modified: expect.arrayContaining([
expect.objectContaining({
id: 'CHART_CUSTOMIZATION_DIVIDER-1',
title: 'Second Edit',
}),
]),
}),
}),
),
);
}, 30000);
test('empty state disappears when a filter is added via dropdown', async () => {
defaultRender(defaultState(), {
...props,

View File

@@ -57,6 +57,7 @@ import {
isFilterId,
isChartCustomizationId,
transformDividerId,
isDivider,
} from './utils';
import { ConfigModalContent } from './ConfigModalContent';
import ConfigModalSidebar from './ConfigModalSidebar';
@@ -470,9 +471,20 @@ function FiltersConfigModal({
[modalSaveLogic, setSaveAlertVisible],
);
const handleValuesChange = useMemo(
() => debouncedHandleErroredItems,
[debouncedHandleErroredItems],
const handleValuesChange = useCallback(
(changedValues: Partial<NativeFiltersForm>) => {
debouncedHandleErroredItems();
// DividerConfigForm doesn't call handleModifyItem on change the way
// FiltersConfigForm does, so detect divider field changes here and mark
// the divider as modified so canSave becomes true and the save payload
// includes the updated divider values.
Object.keys(changedValues?.filters ?? {}).forEach(id => {
if (isDivider(id)) {
handleModifyItem(id);
}
});
},
[debouncedHandleErroredItems, handleModifyItem],
);
const handleActiveFilterPanelChange = useCallback(

View File

@@ -287,7 +287,6 @@ function processGroupByCustomizations(
>,
): {
groupby?: string[];
order_by_cols?: string[];
x_axis?: string;
series?: string;
columns?: string[];
@@ -332,7 +331,6 @@ function processGroupByCustomizations(
const xAxisColumn = chart.form_data?.x_axis;
const groupByColumns: string[] = [];
let orderByConfig: string[] | undefined;
let heatmapColumnAdded = false;
matchingCustomizations.forEach(item => {
@@ -380,12 +378,6 @@ function processGroupByCustomizations(
}
});
}
const sortMetric = item.controlValues?.sortMetric;
const sortAscending = item.controlValues?.sortAscending;
if (sortMetric) {
orderByConfig = [JSON.stringify([sortMetric, !sortAscending])];
}
});
const groupByFormData = applyChartSpecificGroupBy(
@@ -395,10 +387,6 @@ function processGroupByCustomizations(
xAxisColumn,
);
if (orderByConfig) {
groupByFormData.order_by_cols = orderByConfig;
}
return groupByFormData;
}

View File

@@ -99,9 +99,9 @@ test('migrateChartCustomization handles basic legacy format', () => {
expect(result.cascadeParentIds).toEqual([]);
expect(result.controlValues).toEqual({
sortAscending: true,
sortMetric: 'count',
canSelectMultiple: true,
});
expect(result.controlValues).not.toHaveProperty('sortMetric');
});
test('migrateChartCustomization handles dataset as string', () => {
@@ -301,11 +301,31 @@ test('migrateChartCustomization merges controlValues', () => {
expect(result.controlValues).toEqual({
sortAscending: false,
sortMetric: undefined,
canSelectMultiple: undefined,
enableEmptyFilter: true,
customSetting: 'value',
});
expect(result.controlValues).not.toHaveProperty('sortMetric');
});
test('migrateChartCustomization drops sortMetric nested in controlValues', () => {
const legacy = {
id: 'CUSTOMIZATION-1',
customization: {
name: 'Test',
dataset: 1,
column: 'country',
controlValues: {
sortMetric: 'count',
enableEmptyFilter: true,
},
},
};
const result = migrateChartCustomization(legacy);
expect(result.controlValues).not.toHaveProperty('sortMetric');
expect(result.controlValues.enableEmptyFilter).toBe(true);
});
test('migrateChartCustomization preserves removed flag', () => {

View File

@@ -78,12 +78,15 @@ export function migrateChartCustomization(
const controlValues: ChartCustomization['controlValues'] = {
sortAscending: customization.sortAscending,
sortMetric: customization.sortMetric,
canSelectMultiple: customization.canSelectMultiple,
};
if (customization.controlValues) {
Object.assign(controlValues, customization.controlValues);
Object.entries(customization.controlValues).forEach(([key, value]) => {
if (key !== 'sortMetric') {
controlValues[key] = value;
}
});
}
let defaultDataMask = customization.defaultDataMask || {

View File

@@ -17,12 +17,20 @@
* under the License.
*/
import { useMemo, useCallback, useRef, useState } from 'react';
import { getTimeFormatter, safeHtmlSpan, TimeFormats } from '@superset-ui/core';
import {
getTimeFormatter,
safeHtmlSpan,
TimeFormats,
getMetricLabel,
QueryFormMetric,
} from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Constants } from '@superset-ui/core/components';
import { GenericDataType } from '@apache-superset/core/common';
import type { IRowNode } from 'ag-grid-community';
const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
const CONTRIBUTION_SUFFIX = '__contribution';
export function useGridColumns(
colnames: string[] | undefined,
@@ -37,10 +45,33 @@ export function useGridColumns(
.filter((column: string) => Object.keys(data[0]).includes(column))
.map((key, index) => {
const colType = coltypes?.[index];
const headerLabel = columnDisplayNames?.[key] ?? key;
const rawHeader = columnDisplayNames?.[key] ?? key;
let cleaned = rawHeader;
let suffix = '';
if (rawHeader.endsWith(CONTRIBUTION_SUFFIX)) {
cleaned = rawHeader.slice(
0,
rawHeader.length - CONTRIBUTION_SUFFIX.length,
);
suffix = ` (${t('contribution')})`;
}
try {
const parsed = JSON.parse(cleaned);
if (parsed && typeof parsed === 'object') {
cleaned = getMetricLabel(parsed as QueryFormMetric);
}
} catch {
/* not a JSON-encoded metric keep original display name */
}
const cleanHeader = `${cleaned}${suffix}`;
return {
label: key,
headerName: headerLabel,
headerName: cleanHeader,
render: ({ value }: { value: unknown }) => {
if (value === true) {
return Constants.BOOL_TRUE_DISPLAY;

View File

@@ -227,4 +227,44 @@ describe('DataTablesPane', () => {
screen.queryByLabelText('Collapse data panel'),
).not.toBeInTheDocument();
});
test('Should handle column label rendering and clean up headers properly via hook', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A111%7D',
{
result: [
{
data: [
{
plain_column: 'val1',
revenue__contribution: 'val2',
'{"label": "Custom Metric"}': 'val3',
'{"label": "Custom Metric"}__contribution': 'val4',
},
],
colnames: [
'plain_column',
'revenue__contribution',
'{"label": "Custom Metric"}',
'{"label": "Custom Metric"}__contribution',
],
coltypes: [1, 1, 1, 1],
rowcount: 1,
sql_rowcount: 1,
},
],
},
);
const props = createDataTablesPaneProps(111);
render(<DataTablesPane {...props} />, { useRedux: true });
userEvent.click(screen.getByText('Results'));
expect(await screen.findByText('plain_column')).toBeVisible();
expect(screen.getByText('revenue (contribution)')).toBeVisible();
expect(screen.getByText('Custom Metric')).toBeVisible();
expect(screen.getByText('Custom Metric (contribution)')).toBeVisible();
fetchMock.clearHistory().removeRoutes();
});
});

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