Compare commits

...

33 Commits

Author SHA1 Message Date
dependabot[bot]
185d2e604d chore(deps): bump github/codeql-action/analyze from 4.37.2 to 4.37.3
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.2 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e0647621c2...e4fba868fa)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 07:49:40 +00:00
Evan Rusackas
dac69f9bcd feat(pie): geometric recentering and scaling for partial arcs (#42151)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 22:27:11 -07:00
Rehan Islam
d984fb08a4 feat(explore): add download control to standalone charts (#42238) 2026-07-28 22:06:45 -07:00
jesperct
8be430014e fix(dashboard): make Clear All clear filters that have default values (#42111) 2026-07-28 22:05:51 -07:00
aikawa-ohno
00c60bf2de fix(i18n): Update Japanese translations (#42444) 2026-07-28 17:04:47 -07:00
Evan Rusackas
1d4c3a2adb fix(core): don't blank a datetime column when its format coerces every value to NaT (#42405)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-28 15:18:53 -07:00
Elizabeth Thompson
4a4f0685fd fix(reports): prevent bad-date crontab from crashing the report scheduler for all tenants (#42486) 2026-07-28 15:02:36 -07:00
Kobi Hikri
22ad3f2c34 fix(ci): make the Dependency Review check able to fail again (#42537) 2026-07-28 11:52:12 -07:00
Joe Li
9cdb555255 fix(docker): isolate tagged images from host packages (#42249) 2026-07-28 11:51:48 -07:00
Grégoire Gailly
fc59d9c0cf fix(i18n): improve french translations (#42470)
Co-authored-by: Elsanbot <elsanbot@elsan.care>
2026-07-28 11:48:31 -07:00
dependabot[bot]
623348f190 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.64.0 to 8.65.0 in /superset-frontend in the typescript-eslint group across 1 directory (#42512)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 10:29:58 -07:00
Amin Ghadersohi
5aaf9d337b fix(jinja): handle non-JSON body with JSON content-type in get_form_data (#42196) 2026-07-28 09:18:08 -07:00
JUST.in DO IT
181f5bc9c1 fix(mcp): capture created chart/dashboard id in generate_chart/generate_dashboard audit logs (#42367) 2026-07-28 09:16:58 -07:00
Đỗ Trọng Hải
e426fb7bc6 chore(ci): correct GH event type for opening new issue and PR in supersetbot orglabel job (#42424)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-28 09:16:41 -07:00
Evan Rusackas
b5c8d4a5df fix(mixed-timeseries): stop duplicating first metric in multi-metric + group-by series names (#40146)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-28 09:15:26 -07:00
dependabot[bot]
236c9a52fd chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.63.0 to 8.65.0 in /superset-websocket (#42515)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:06:12 -07:00
Mehmet Salih Yavuz
b983161eef feat(async): cancel running chart queries under GLOBAL_ASYNC_QUERIES (#42305) 2026-07-28 18:02:16 +03:00
Evan Rusackas
7b767f6c1d chore(ci): swap linkinator-action for lychee-action in docs link checker (#42501)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:33:01 +07:00
dependabot[bot]
526cebac73 chore(deps-dev): bump prettier from 3.9.5 to 3.9.6 in /superset-websocket (#42514)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 21:30:23 +07:00
dependabot[bot]
39f9b7029b chore(deps): bump github/codeql-action/analyze from 4.37.1 to 4.37.2 (#42520)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 16:05:23 +03:00
dependabot[bot]
25a985893d chore(deps-dev): bump typescript-eslint from 8.64.0 to 8.65.0 in /superset-websocket (#42516)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:49:06 -07:00
dependabot[bot]
ad15c4ddd1 chore(deps): bump the storybook group in /docs with 2 updates (#42517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:49:02 -07:00
dependabot[bot]
a301e7c511 chore(deps-dev): bump the typescript-eslint group in /docs with 3 updates (#42518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:59 -07:00
dependabot[bot]
dd7bbe0adf chore(deps-dev): bump prettier from 3.9.5 to 3.9.6 in /docs (#42519)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:56 -07:00
dependabot[bot]
2e81eb580f chore(deps): bump baseline-browser-mapping from 2.10.43 to 2.10.44 in /docs (#42521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:49 -07:00
dependabot[bot]
a970838b5c chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.2 (#42522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:46 -07:00
dependabot[bot]
2c061f1e05 chore(deps-dev): bump the storybook group in /superset-frontend with 5 updates (#42523)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:42 -07:00
dependabot[bot]
be012c8faa chore(deps): bump markdown-to-jsx from 9.8.2 to 9.9.0 in /superset-frontend (#42524)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:39 -07:00
dependabot[bot]
e754b73af2 chore(deps-dev): bump baseline-browser-mapping from 2.10.43 to 2.10.44 in /superset-frontend (#42526)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:35 -07:00
dependabot[bot]
435e7825a6 chore(deps-dev): bump prettier from 3.9.5 to 3.9.6 in /superset-frontend (#42527)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:32 -07:00
Mehmet Salih Yavuz
ecc7f726a4 fix: guard potential null derefs and remove dead branches (#42358)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-07-28 11:43:46 +03:00
PRATHAMESH HUKKERI
26b6f7bb5d fix(dashboard): preserve refresh_frequency when absent from save data (#42354)
Co-authored-by: Prathamesh Hukkeri <prathamesh04@users.noreply.github.com>
2026-07-27 23:13:11 -07:00
Phuc Hung Nguyen
a10c3f0b1d test(result_set): add regression test for empty result set column metadata (#35962)
Co-authored-by: Phuc Hung Nguyen <phucnguyen@geotab.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 23:08:59 -07:00
60 changed files with 4396 additions and 2543 deletions

View File

@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
category: "/language:${{matrix.language}}"

View File

@@ -32,7 +32,6 @@ jobs:
persist-credentials: false
- name: "Dependency Review"
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
continue-on-error: true
with:
fail-on-severity: critical
# compatible/incompatible licenses addressed here: https://www.apache.org/legal/resolved.html

View File

@@ -2,10 +2,10 @@ name: supersetbot orglabel based on author
on:
issues:
types: [created, edited]
types: [opened, edited]
pull_request:
types: [created, edited]
types: [opened, edited]
jobs:
superbot-orglabel:
@@ -30,4 +30,4 @@ jobs:
# Label the issue with the appropriate org using supersetbot
# - this requires for the author to be publicly associated with their org
# - and for the org to be listed in `supersetbot/src/metadata.js`
supersetbot orglabel --issue ${{ github.event.number }} --repo ${{ github.repository }} || true
supersetbot orglabel --issue ${{ github.event.number || github.event.issue.number }} --repo ${{ github.repository }} || true

View File

@@ -21,8 +21,8 @@ permissions:
contents: read
jobs:
linkinator:
# See docs here: https://github.com/marketplace/actions/linkinator
lychee:
# See docs here: https://github.com/lycheeverse/lychee-action
# Only run on pull_request, not workflow_run
if: github.event_name == 'pull_request'
name: Link Checking
@@ -31,37 +31,52 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
# Restores the on-disk request cache (.lycheecache) lychee writes when
# `--cache` is set, so re-runs don't re-check every external link from
# scratch. Key never matches exactly (github.run_id is unique per run),
# so actions/cache always saves a fresh copy afterwards; restore-keys
# falls back to the most recent prior cache on lookup.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .lycheecache
key: lychee-cache-${{ github.run_id }}
restore-keys: lychee-cache-
# v2.8.0 and v2.9.0 are both on ASF Infra's action allowlist
# (apache/infrastructure-actions approved_patterns.yml) as of this
# writing. Do not bump past v2.9.0 without opening an Infra ticket
# to allow the new SHA first!
- uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
with:
paths: "**/*.md, **/*.mdx"
linksToSkip: >-
^https://github.com/apache/(superset|incubator-superset)/(pull|issues)/\d+,
^https://github.com/apache/(superset|incubator-superset)/commit/[a-f0-9]+,
superset-frontend/.*CHANGELOG\.md,
http://localhost:8088/,
http://127.0.0.1:3000/,
http://localhost:9001/,
https://charts.bitnami.com/bitnami,
https://www.li.me/,
https://www.fanatics.com/,
https://tails.com/gb/,
https://www.techaudit.info/,
https://avetilearning.com/,
https://www.udemy.com/,
https://trustmedis.com/,
http://theiconic.com.au/,
https://dev.mysql.com/doc/refman/5.7/en/innodb-limits.html,
^https://img\.shields\.io/.*,
https://vkusvill.ru/,
https://www.linkedin.com/in/mark-thomas-b16751158/,
https://theiconic.com.au/,
https://wattbewerb.de/,
https://timbr.ai/,
https://opensource.org/license/apache-2-0,
https://www.plaidcloud.com/
fail: false
args: >-
--verbose --no-progress
--cache --max-cache-age 7d
--exclude '^https://github.com/apache/(superset|incubator-superset)/(pull|issues)/\d+'
--exclude '^https://github.com/apache/(superset|incubator-superset)/commit/[a-f0-9]+'
--exclude 'superset-frontend/.*CHANGELOG\.md'
--exclude 'http://localhost:8088/'
--exclude 'http://127.0.0.1:3000/'
--exclude 'http://localhost:9001/'
--exclude 'https://charts.bitnami.com/bitnami'
--exclude 'https://www.li.me/'
--exclude 'https://www.fanatics.com/'
--exclude 'https://tails.com/gb/'
--exclude 'https://www.techaudit.info/'
--exclude 'https://avetilearning.com/'
--exclude 'https://www.udemy.com/'
--exclude 'https://trustmedis.com/'
--exclude 'http://theiconic.com.au/'
--exclude 'https://dev.mysql.com/doc/refman/5.7/en/innodb-limits.html'
--exclude '^https://img\.shields\.io/.*'
--exclude 'https://vkusvill.ru/'
--exclude 'https://www.linkedin.com/in/mark-thomas-b16751158/'
--exclude 'https://theiconic.com.au/'
--exclude 'https://wattbewerb.de/'
--exclude 'https://timbr.ai/'
--exclude 'https://opensource.org/license/apache-2-0'
--exclude 'https://www.plaidcloud.com/'
'./**/*.md' './**/*.mdx'
build-on-pr:
# Build docs when PR changes docs/** (uses committed databases.json)

View File

@@ -28,8 +28,10 @@ x-superset-image: &superset-image apachesuperset.docker.scarf.sh/apache/superset
x-superset-volumes:
&superset-volumes # /app/pythonpath_docker will be appended to the PYTHONPATH in the final container
- ./docker:/app/docker
- ./superset-core:/app/superset-core
- superset_home:/app/superset_home
x-superset-environment: &superset-environment
# Tagged images must use their bundled packages rather than host bind mounts.
DEV_MODE: "false"
services:
redis:
@@ -58,6 +60,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
environment: *superset-environment
image: *superset-image
container_name: superset_app
command: ["/app/docker/docker-bootstrap.sh", "app-gunicorn"]
@@ -79,6 +82,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
environment: *superset-environment
depends_on:
db:
condition: service_started
@@ -98,6 +102,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
environment: *superset-environment
restart: unless-stopped
depends_on:
superset-init:
@@ -120,6 +125,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
environment: *superset-environment
restart: unless-stopped
depends_on:
superset-init:

View File

@@ -58,11 +58,11 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.0",
"@storybook/addon-docs": "^10.5.3",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.46",
"antd": "^6.5.1",
"baseline-browser-mapping": "^2.10.43",
"baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806",
"docusaurus-plugin-openapi-docs": "^5.1.2",
"docusaurus-theme-openapi-docs": "^5.1.2",
@@ -77,7 +77,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.0",
"storybook": "^10.5.3",
"swagger-ui-react": "^5.32.9",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
@@ -89,16 +89,16 @@
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.7.0",
"prettier": "^3.9.5",
"prettier": "^3.9.6",
"typescript": "~6.0.3",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "^8.65.0",
"webpack": "^5.108.2"
},
"browserslist": {

View File

@@ -4005,23 +4005,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.0":
version "10.5.2"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.2.tgz#e8023d1a68f637de9188be093f719895a8252d19"
integrity sha512-MoBANDsh5qEA14U+JaBoQcYsKbayJDDMopigFN0NdVAsZTdBfVIsL7cnjTFBL6ubB3ifb5M0tCXbScpml1KqiQ==
"@storybook/addon-docs@^10.5.3":
version "10.5.3"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.3.tgz#b5666cf20361d85c98cc836b2bfcdbbb72af3e97"
integrity sha512-MI1VDMSMQk78YxjIdt7WlrVOiA3TzTP00lRed1LeXh0fCvA9jxz9YXJI2+XigsLaxCSuOAEf/l35/GTLDMHD8A==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.2"
"@storybook/csf-plugin" "10.5.3"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.2"
"@storybook/react-dom-shim" "10.5.3"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.2":
version "10.5.2"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.2.tgz#a359dffe0b842dfffacb0e94ad98da0ccb13ba69"
integrity sha512-PK/wXiALFf88mt4HmDtiZJ6NRvhExSXEM9uFIN+OIHxGqg7Xbp6MB0SPdhsTbMY9720ahiu/DJx5iIzkidcA3w==
"@storybook/csf-plugin@10.5.3":
version "10.5.3"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.3.tgz#0818709c2761910f1e42710f6231f773eb97fb41"
integrity sha512-mkPq6zru8fN5+46uC1cZEbKW2ws1hh9KvF4g4/Gu8pNbKnvqULPhk0/Bf0ZCtlr7zI7DvcFhyCy3dbvN+2n4Gw==
dependencies:
unplugin "^2.3.5"
@@ -4035,10 +4035,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.2":
version "10.5.2"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.2.tgz#993077c517ee1a1c065c82bf0143ae08856a3c90"
integrity sha512-TbdYVLuD7gwj1CFsDJhCHUiwfVmzFWzalKEUGy9XgXyNpyOV1CYRsdmRdhaOHgmn2ljQZuTAxSnG7NlElghVaw==
"@storybook/react-dom-shim@10.5.3":
version "10.5.3"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz#e176a6549aa02002e375cb66467a00c5403745f8"
integrity sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -5527,100 +5527,100 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.64.0", "@typescript-eslint/eslint-plugin@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz#71a0c3d5f8a5e6c5dfdb4f0f04bd1bfb572d5e24"
integrity sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==
"@typescript-eslint/eslint-plugin@8.65.0", "@typescript-eslint/eslint-plugin@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz#0a58df6fea8c0bf6b396f518077099bc8b762bb5"
integrity sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/type-utils" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/type-utils" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.64.0", "@typescript-eslint/parser@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.64.0.tgz#c9864a1cc28a13ff29a7314fbdef0528bb122f72"
integrity sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==
"@typescript-eslint/parser@8.65.0", "@typescript-eslint/parser@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc"
integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==
dependencies:
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.64.0.tgz#14c4e29390d7325a7f8a1218c2788fd649b85da6"
integrity sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==
"@typescript-eslint/project-service@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5"
integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.64.0"
"@typescript-eslint/types" "^8.64.0"
"@typescript-eslint/tsconfig-utils" "^8.65.0"
"@typescript-eslint/types" "^8.65.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz#d45f15304a94c85c39db317b717b158fb6259958"
integrity sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==
"@typescript-eslint/scope-manager@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d"
integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
"@typescript-eslint/tsconfig-utils@8.64.0", "@typescript-eslint/tsconfig-utils@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851"
integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==
"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3"
integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==
"@typescript-eslint/type-utils@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz#106fa7d58cf9cf7758f3dd8e426ac8237eceacf3"
integrity sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==
"@typescript-eslint/type-utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz#d316d7522d93cff4cd14f305e02f3df2d804f9c1"
integrity sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.64.0", "@typescript-eslint/types@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b"
integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==
"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f"
integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==
"@typescript-eslint/typescript-estree@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz#b8d51255e2d726eb4bd80d397a4fb4170c02eecc"
integrity sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==
"@typescript-eslint/typescript-estree@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af"
integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==
dependencies:
"@typescript-eslint/project-service" "8.64.0"
"@typescript-eslint/tsconfig-utils" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/project-service" "8.65.0"
"@typescript-eslint/tsconfig-utils" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.64.0.tgz#98bb2010cfb754b41985b9c93e6e8b3dcd7bd600"
integrity sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==
"@typescript-eslint/utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72"
integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/visitor-keys@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz#7a08421d10e54960733352cd7c95fab1784e8473"
integrity sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==
"@typescript-eslint/visitor-keys@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6"
integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/types" "8.65.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -6368,10 +6368,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.10.43, baseline-browser-mapping@^2.9.19:
version "2.10.43"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz#7b5d11590ce5acdbe4859443e3c940e81ce8c02d"
integrity sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.10.44, baseline-browser-mapping@^2.9.19:
version "2.10.44"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz#2e074675e640b67cb3835c54c11aa2dd9119c0f8"
integrity sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==
batch@0.6.1:
version "0.6.1"
@@ -13029,10 +13029,10 @@ prettier-linter-helpers@^1.0.1:
dependencies:
fast-diff "^1.1.2"
prettier@^3.9.5:
version "3.9.5"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
prettier@^3.9.6:
version "3.9.6"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==
pretty-error@^4.0.0:
version "4.0.0"
@@ -14627,10 +14627,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.0:
version "10.5.2"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.2.tgz#751aca53cbeea086886d84ceb46c8a29a5f802f3"
integrity sha512-zkYxVZoDMj8njzZc3EH5UyY7885wpi9a1mmWVwFiNHSo+i5r2Go84E2OI1cdOctRymLkNvgs1j5jqAKA0ftBqg==
storybook@^10.5.3:
version "10.5.3"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.3.tgz#72ee7cc02e3b6353eeec9633c2367437cf2ac86e"
integrity sha512-c8Wumu5qz0N2fnzWBxcPzUsY+8BpKBKChNyl4BEh9qhMV6KW587gL8il8emRB+4Hay+zMjDHA7cIeTkl4FKYuw==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
@@ -15309,15 +15309,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.64.0:
version "8.64.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.64.0.tgz#4984dae4de9dc8bf892acf5c394d0a2a5f08c3e1"
integrity sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==
typescript-eslint@^8.65.0:
version "8.65.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.65.0.tgz#754c953fd6a9a3d36fa54015bdba80a6eb2a4957"
integrity sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==
dependencies:
"@typescript-eslint/eslint-plugin" "8.64.0"
"@typescript-eslint/parser" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/eslint-plugin" "8.65.0"
"@typescript-eslint/parser" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
typescript@~6.0.3:
version "6.0.3"

View File

@@ -39,6 +39,7 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({403, 429})
PATTERNS = {
"python": [
r"^\.github/workflows/.*python",
r"^docker-compose-image-tag\.yml$",
r"^tests/",
r"^superset/",
r"^scripts/",

View File

@@ -111,7 +111,7 @@
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.26.0",
"markdown-to-jsx": "^9.8.2",
"markdown-to-jsx": "^9.9.0",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
@@ -182,9 +182,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.2",
"@storybook/addon-links": "10.5.2",
"@storybook/react-webpack5": "10.5.2",
"@storybook/addon-docs": "10.5.3",
"@storybook/addon-links": "10.5.3",
"@storybook/react-webpack5": "10.5.3",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.46",
@@ -215,13 +215,13 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.10.43",
"baseline-browser-mapping": "^2.10.44",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -240,7 +240,7 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.2",
"eslint-plugin-storybook": "10.5.3",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -262,7 +262,7 @@
"oxlint": "^1.74.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"prettier": "3.9.5",
"prettier": "3.9.6",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
@@ -272,7 +272,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -10329,16 +10329,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.2.tgz",
"integrity": "sha512-MoBANDsh5qEA14U+JaBoQcYsKbayJDDMopigFN0NdVAsZTdBfVIsL7cnjTFBL6ubB3ifb5M0tCXbScpml1KqiQ==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.3.tgz",
"integrity": "sha512-MI1VDMSMQk78YxjIdt7WlrVOiA3TzTP00lRed1LeXh0fCvA9jxz9YXJI2+XigsLaxCSuOAEf/l35/GTLDMHD8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.2",
"@storybook/csf-plugin": "10.5.3",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.2",
"@storybook/react-dom-shim": "10.5.3",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -10349,7 +10349,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10358,9 +10358,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.2.tgz",
"integrity": "sha512-PK/wXiALFf88mt4HmDtiZJ6NRvhExSXEM9uFIN+OIHxGqg7Xbp6MB0SPdhsTbMY9720ahiu/DJx5iIzkidcA3w==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.3.tgz",
"integrity": "sha512-mkPq6zru8fN5+46uC1cZEbKW2ws1hh9KvF4g4/Gu8pNbKnvqULPhk0/Bf0ZCtlr7zI7DvcFhyCy3dbvN+2n4Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10373,7 +10373,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "10.5.2",
"storybook": "10.5.3",
"vite": "*",
"webpack": "*"
},
@@ -10393,9 +10393,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.2.tgz",
"integrity": "sha512-TbdYVLuD7gwj1CFsDJhCHUiwfVmzFWzalKEUGy9XgXyNpyOV1CYRsdmRdhaOHgmn2ljQZuTAxSnG7NlElghVaw==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz",
"integrity": "sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10407,7 +10407,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10419,9 +10419,9 @@
}
},
"node_modules/@storybook/addon-links": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.2.tgz",
"integrity": "sha512-9Xk0gTpe8CJNR8a2UfgJtCPIZx1hQGUzYx7iTdfzJFQ9Y79ljGeOdq+d/O1zJ3G2uVqHhjHyD8/V+iolhfhdxg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.3.tgz",
"integrity": "sha512-awu6nBV/MRFv+zu9hIqFrqnKa37LnWfZ3/UHeS68il0QxWfy6uSU1dfpxqiYbCjs9Ct0+bsaO6ZaPamA+jlFLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10434,7 +10434,7 @@
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10528,15 +10528,15 @@
}
},
"node_modules/@storybook/react-webpack5": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.2.tgz",
"integrity": "sha512-nffAS7ipJY0ltaPuM7ELBO0S4SiSLxAR0f9oUrYVbSKpYUVC0r1MzHVTyj/eqzzS6Dwq5yvmvCrwgc3M3Htdmg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.3.tgz",
"integrity": "sha512-5uGVxywkT+/Bge4JwaReIj3TMKeceS6trkFwRc70RJnTWUQtlAbUyodI1OMQifRAs/1190+ptKBl0qOAbDyINw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-webpack5": "10.5.2",
"@storybook/preset-react-webpack": "10.5.2",
"@storybook/react": "10.5.2"
"@storybook/builder-webpack5": "10.5.3",
"@storybook/preset-react-webpack": "10.5.3",
"@storybook/react": "10.5.3"
},
"funding": {
"type": "opencollective",
@@ -10545,7 +10545,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10555,13 +10555,13 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.2.tgz",
"integrity": "sha512-iFPMPeRCinW5pQJlcQT1EiNv0+eKXuLu/3t53g/xY9cGY0QfQtAMzxf0c2QIeAnJOxu3BWTrkdL3MKDHuDnPwA==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.3.tgz",
"integrity": "sha512-IY8OlaOFRgsOCA4K+fBmTfEbVArvpqhjc/TYKC7Tq9hgY449stMmj89XNDeviL+ZXc7fL+S80EMiZnCPoF2Acw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.2",
"@storybook/core-webpack": "10.5.3",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
@@ -10583,7 +10583,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"typescript": {
@@ -10592,9 +10592,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.2.tgz",
"integrity": "sha512-XNzXTJmvAKXpvcrWHDWnIC8jZR4GBgTUH+FF9T5lSmU9ZL46T2wVaJAu2GUXfDLmNwLHZXusbYljbTD7Kswamg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.3.tgz",
"integrity": "sha512-A7DIGq4XcOXkL7g4Dc/XyntlfStvYvBp+iIkblA5mVOJ/I0n7MxI0jgrLoG+thP5jA72HLdSpjedXa3GUQOPrA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10605,17 +10605,17 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.2"
"storybook": "10.5.3"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.2.tgz",
"integrity": "sha512-XV5XV0PqgPEzBwilcEnowqbWkjt7r8qvTm7Zi3vSjrr1Aq6uLpc+9rV51bXsoOgcAFLZv9VQMbAu4B7LzHU/4g==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.3.tgz",
"integrity": "sha512-i93PdwMFAO0hqFhqdrNEhcwBnPb416zELssZTnxjjClJq4qRIGrGxch1NZ33IN4LGaF3fEY68byGuX6AHcHo6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.2",
"@storybook/core-webpack": "10.5.3",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.7.1",
"magic-string": "^0.30.5",
@@ -10632,7 +10632,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"typescript": {
@@ -10641,9 +10641,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.2.tgz",
"integrity": "sha512-XNzXTJmvAKXpvcrWHDWnIC8jZR4GBgTUH+FF9T5lSmU9ZL46T2wVaJAu2GUXfDLmNwLHZXusbYljbTD7Kswamg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.3.tgz",
"integrity": "sha512-A7DIGq4XcOXkL7g4Dc/XyntlfStvYvBp+iIkblA5mVOJ/I0n7MxI0jgrLoG+thP5jA72HLdSpjedXa3GUQOPrA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10654,18 +10654,18 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.2"
"storybook": "10.5.3"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.2.tgz",
"integrity": "sha512-DQkTEvQ3Tn5ndyZnOyNTnYuJWBZdnUsVFuvImtt1H6QyHsZGhl35/3CqRwERa/P10Sw/6vLP5DS+KhDCz1hWbg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.3.tgz",
"integrity": "sha512-d/CK78xgA7DDvqnxkqcYmiTjomE4ch2TWvk0O8/xHQWW6y0nMjKtsZbmUBfZ0QcdYdWq7dErzfbG7YAzxDi7Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.2",
"@storybook/react-dom-shim": "10.5.3",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -10678,7 +10678,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10694,9 +10694,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.2.tgz",
"integrity": "sha512-TbdYVLuD7gwj1CFsDJhCHUiwfVmzFWzalKEUGy9XgXyNpyOV1CYRsdmRdhaOHgmn2ljQZuTAxSnG7NlElghVaw==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz",
"integrity": "sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10708,7 +10708,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -12776,17 +12776,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
"integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/type-utils": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/type-utils": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -12799,7 +12799,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.64.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -12839,69 +12839,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
@@ -12924,29 +12861,15 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz",
"integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0"
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -12974,15 +12897,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
"integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -12998,116 +12921,10 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz",
"integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.64.0",
"@typescript-eslint/types": "^8.64.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz",
"integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz",
"integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.64.0",
"@typescript-eslint/tsconfig-utils": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -13146,38 +12963,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -13201,19 +12986,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -13231,16 +13003,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz",
"integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0"
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -13254,120 +13026,14 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz",
"integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.64.0",
"@typescript-eslint/types": "^8.64.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz",
"integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz",
"integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.64.0",
"@typescript-eslint/tsconfig-utils": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/utils/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz",
"integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -15633,9 +15299,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
"version": "2.10.44",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz",
"integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -20220,18 +19886,18 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.2.tgz",
"integrity": "sha512-BPTbb8OKNAlQ2XubEQ6H1TeHG9nMygLrHC8cSVTfdIat65qbS568WYgNg//zlJWtZ9ZamyVkCp/na+LBbFPseA==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.3.tgz",
"integrity": "sha512-dreVGgQvTTOvPTrnn71uogs3E7skbODW3Ya1cHxupznzuIofLCzy+7zWJ4ObsEZ1MsOcofQlSaQuV9oJBszLoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "^8.48.0",
"@typescript-eslint/utils": "^8.48.0"
"@typescript-eslint/types": "^8.60.0",
"@typescript-eslint/utils": "^8.60.0"
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "10.5.2"
"storybook": "10.5.3"
}
},
"node_modules/eslint-plugin-testing-library": {
@@ -29085,9 +28751,9 @@
}
},
"node_modules/markdown-to-jsx": {
"version": "9.8.2",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.2.tgz",
"integrity": "sha512-rWUuxKB5NsuJmSfUOuXkQ0O5qk0J/Lr3Lk6dzxKoKQI/jeHYlsVfz3zJdMLAhI46hHoXDYERWhtBOiqtWDZ4LA==",
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.9.0.tgz",
"integrity": "sha512-3YRQuriODgdMr9EeY//Ya/104p978bY0nOwmc85IGxHixqOlhNDgq7IJTsLOdmFxjMFKJ6bVzUav30wWVJZOJQ==",
"license": "MIT",
"engines": {
"node": ">= 18"
@@ -33660,9 +33326,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
@@ -38698,9 +38364,9 @@
}
},
"node_modules/storybook": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.2.tgz",
"integrity": "sha512-zkYxVZoDMj8njzZc3EH5UyY7885wpi9a1mmWVwFiNHSo+i5r2Go84E2OI1cdOctRymLkNvgs1j5jqAKA0ftBqg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.3.tgz",
"integrity": "sha512-c8Wumu5qz0N2fnzWBxcPzUsY+8BpKBKChNyl4BEh9qhMV6KW587gL8il8emRB+4Hay+zMjDHA7cIeTkl4FKYuw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -41420,9 +41086,9 @@
}
},
"node_modules/unplugin/node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -196,7 +196,7 @@
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.26.0",
"markdown-to-jsx": "^9.8.2",
"markdown-to-jsx": "^9.9.0",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
@@ -267,9 +267,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.2",
"@storybook/addon-links": "10.5.2",
"@storybook/react-webpack5": "10.5.2",
"@storybook/addon-docs": "10.5.3",
"@storybook/addon-links": "10.5.3",
"@storybook/react-webpack5": "10.5.3",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.46",
@@ -300,13 +300,13 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.10.43",
"baseline-browser-mapping": "^2.10.44",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -325,7 +325,7 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.2",
"eslint-plugin-storybook": "10.5.3",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -347,7 +347,7 @@
"oxlint": "^1.74.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"prettier": "3.9.5",
"prettier": "3.9.6",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
@@ -357,7 +357,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",

View File

@@ -457,13 +457,23 @@ export default function transformProps(
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
const labelMapValues = rawLabelMap?.[seriesName];
let displayName: string;
if (groupby.length > 0) {
// When we have groupby, format as "metric, dimension"
// When we have groupby, format as "metric, dimension". Each series
// belongs to the metric recorded in its label-map tuple
// ([metric, ...dimensions]) — always using the first metric would
// prepend it to every other metric's series (#37921). Tuples without
// a metric part fall back to the first metric as before.
const metricDisplayName =
labelMapValues && labelMapValues.length > 1
? getMetricDisplayName(labelMapValues[0], verboseMap)
: MetricDisplayNameA;
const metricPart: string = showQueryIdentifiers
? `${MetricDisplayNameA} (Query A)`
: MetricDisplayNameA;
? `${metricDisplayName} (Query A)`
: metricDisplayName;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
@@ -471,8 +481,6 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
}
const labelMapValues = rawLabelMap?.[seriesName];
if (labelMapValues) {
displayLabelMap[displayName] = labelMapValues;
}
@@ -536,13 +544,23 @@ export default function transformProps(
const seriesEntry = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
let displayName: string;
if (groupbyB.length > 0) {
// When we have groupby, format as "metric, dimension"
// When we have groupby, format as "metric, dimension". Each series
// belongs to the metric recorded in its label-map tuple
// ([metric, ...dimensions]) — always using the first metric would
// prepend it to every other metric's series (#37921). Tuples without
// a metric part fall back to the first metric as before.
const metricDisplayName =
labelMapValuesB && labelMapValuesB.length > 1
? getMetricDisplayName(labelMapValuesB[0], verboseMap)
: MetricDisplayNameB;
const metricPart: string = showQueryIdentifiers
? `${MetricDisplayNameB} (Query B)`
: MetricDisplayNameB;
? `${metricDisplayName} (Query B)`
: metricDisplayName;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
@@ -550,8 +568,6 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
}
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
if (labelMapValuesB) {
displayLabelMapB[displayName] = labelMapValuesB;
}

View File

@@ -325,9 +325,8 @@ const config: ControlPanelConfig = {
description: t(
'Total angle covered by the chart, in degrees. ' +
'360° draws a full circle and 180° draws a half donut. ' +
'When the sweep is 180° or less and the start angle is a ' +
'multiple of 90°, the chart is automatically re-centered ' +
'to make use of the empty space.',
'Partial arcs are automatically re-centered and scaled ' +
'to make use of the available space.',
),
renderTrigger: true,
default: DEFAULT_FORM_DATA.sweptAngle,

View File

@@ -39,9 +39,6 @@ import {
EchartsPieLabelType,
PieChartDataItem,
PieChartTransformedProps,
TotalValuePaddingProps,
PaddingResult,
HalfDonut,
} from './types';
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
import {
@@ -76,120 +73,153 @@ export function parseParams({
return [name, formattedValue, formattedPercent];
}
const HALF_DONUT_SWEEP_LIMIT = 180;
/**
* Bounding box of the pie arc in unit coordinates: outer radius = 1,
* mathematical y-up convention matching ECharts' angle convention
* (0° points right, 90° points up, angles sweep clockwise from
* `startAngle` to `startAngle - sweptAngle`).
*/
export interface ArcBoundingBox {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/**
* Geometric configuration for each type of semi-circular layout.
* Computes the bounding box of an annular sector from its angles.
*
* - `centerOffset` — offset of the chart center from the baseline 50% on the X and Y axes.
* Resulting position: `50% + offset`.
* - `totalBase` — base position of the "Total" text as a percentage on the X and Y axes.
* The box is spanned by the outer arc endpoints, the inner arc endpoints
* (which collapse to the pie origin when `innerRatio` is 0), and every axis
* extreme (0°/90°/180°/270°) the arc sweeps through.
*
* The values are empirically tuned so that the "Total" text visually remains
* at the geometric center of the arc after the chart is re-centered. The
* `left`/`right` totalBase values sit 5% inside the shifted chart center
* (60% and 40% respectively) to compensate for the text being positioned by
* its left edge rather than its midpoint.
* @param startAngle - The start angle of the arc in degrees.
* @param sweptAngle - The total angle covered by the arc in degrees.
* @param innerRatio - Inner radius as a fraction of the outer radius (01).
*/
const HALF_DONUT_LAYOUT: Record<
HalfDonut,
{
centerOffset: { x: number; y: number };
totalBase: { left: number; top: number };
}
> = {
top: { centerOffset: { x: 0, y: 20 }, totalBase: { left: 50, top: 68.5 } },
bottom: { centerOffset: { x: 0, y: -20 }, totalBase: { left: 50, top: 30 } },
left: { centerOffset: { x: 10, y: 0 }, totalBase: { left: 55, top: 50 } },
right: { centerOffset: { x: -10, y: 0 }, totalBase: { left: 35, top: 50 } },
none: { centerOffset: { x: 0, y: 0 }, totalBase: { left: 50, top: 50 } },
};
/**
* Determines the type of semicircular layout based on the start angle and swept angle.
*
* All four semicircle orientations are supported:
* - `'top'` — the arc is positioned at the top; the chart center shifts downwards.
* - `'bottom'` — the arc is positioned at the bottom; the chart center shifts upwards.
* - `'left'` — the arc is positioned at the left; the chart center shifts right.
* - `'right'` — the arc is positioned at the right; the chart center shifts left.
*
* @param startAngle - The start angle of the arc in degrees (0360).
* @param sweptAngle - The swept angle of the arc in degrees (10360).
* @returns The type of semicircular layout.
*/
export const getHalfDonut = (
export function getArcBoundingBox(
startAngle: number,
sweptAngle: number,
): HalfDonut => {
if (sweptAngle > HALF_DONUT_SWEEP_LIMIT) return 'none';
innerRatio: number,
): ArcBoundingBox {
if (sweptAngle >= 360) {
return { minX: -1, maxX: 1, minY: -1, maxY: 1 };
}
const toRad = (deg: number) => (deg * Math.PI) / 180;
const endAngle = startAngle - sweptAngle;
const points: [number, number][] = [];
[startAngle, endAngle].forEach(angle => {
const x = Math.cos(toRad(angle));
const y = Math.sin(toRad(angle));
points.push([x, y], [innerRatio * x, innerRatio * y]);
});
for (
let axis = Math.ceil(endAngle / 90) * 90;
axis <= startAngle;
axis += 90
) {
points.push([Math.cos(toRad(axis)), Math.sin(toRad(axis))]);
}
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
};
}
const normalized = startAngle % 360;
/**
* Arcs covering only a sliver of the circle would otherwise scale up without
* bound; cap the fit scale at the factor a quarter arc reaches naturally.
*/
const MAX_RADIUS_SCALE = 2;
if (normalized === 180) return 'top';
if (normalized === 0) return 'bottom';
if (normalized === 270) return 'left';
if (normalized === 90) return 'right';
export interface PieLayout {
/** Pie origin in px, relative to the padded series rect. */
center: [number, number];
/** Inner/outer radius percent strings, scaled to fit the arc's box. */
radius: [string, string];
/** Pie origin in px, in container coordinates (for the graphic component). */
totalAnchor: { x: number; y: number };
}
return 'none';
};
const getHalfDonutLayout = (startAngle: number, sweptAngle: number) =>
HALF_DONUT_LAYOUT[getHalfDonut(startAngle, sweptAngle)];
export function getTotalValuePadding({
chartPadding,
donut,
/**
* Lays out the pie geometrically for any start/sweep angle combination:
* scales the radius until the arc's bounding box fills the available rect
* (so partial arcs reclaim the space a full circle would leave empty) and
* shifts the pie origin so that box is centered. A full circle reproduces
* ECharts' default layout exactly.
*/
export function getPieLayout({
width,
height,
padding,
startAngle,
sweptAngle,
}: TotalValuePaddingProps): PaddingResult {
const safeHeight = height || 1;
const safeWidth = width || 1;
donut,
innerRadius,
outerRadius,
}: {
width: number;
height: number;
padding: { top: number; bottom: number; left: number; right: number };
startAngle: number;
sweptAngle: number;
donut: boolean;
innerRadius: number;
outerRadius: number;
}): PieLayout {
const rectWidth = Math.max(width - padding.left - padding.right, 1);
const rectHeight = Math.max(height - padding.top - padding.bottom, 1);
const innerRatio = donut ? innerRadius / Math.max(outerRadius, 1) : 0;
const box = getArcBoundingBox(startAngle, sweptAngle, innerRatio);
const boxWidth = box.maxX - box.minX;
const boxHeight = box.maxY - box.minY;
const halfType = getHalfDonut(startAngle, sweptAngle);
const layout = HALF_DONUT_LAYOUT[halfType];
const isHalf = halfType !== 'none';
// ECharts resolves percentage radii against min(rect width, height) / 2.
// Grow that basis until the arc's bounding box hits the rect on one axis.
const fullBasis = Math.min(rectWidth, rectHeight) / 2;
const fitBasis = Math.min(rectWidth / boxWidth, rectHeight / boxHeight);
const scale = Math.min(fitBasis / fullBasis, MAX_RADIUS_SCALE);
const outerPx = (outerRadius / 100) * fullBasis * scale;
const calculateTop = (): string => {
if (chartPadding.bottom) {
return donut
? `${layout.totalBase.top - (chartPadding.bottom / safeHeight) * 50}%`
: '0';
}
// Place the pie origin so the arc's box is centered in the rect. Unit y
// points up while screen y points down, hence the sign flip.
const round = (value: number) => Math.round(value * 100) / 100;
const centerX = rectWidth / 2 - ((box.minX + box.maxX) / 2) * outerPx;
const centerY = rectHeight / 2 + ((box.minY + box.maxY) / 2) * outerPx;
if (chartPadding.top || isHalf) {
if (donut) {
return `${layout.totalBase.top + (chartPadding.top / safeHeight) * 50}%`;
}
return `${(chartPadding.top / safeHeight) * 100}%`;
}
return donut ? 'middle' : '0';
};
const calculateLeft = (): string => {
if (chartPadding.right) {
const rightPercent = (chartPadding.right / safeWidth) * 100;
return `${layout.totalBase.left - rightPercent * 0.75}%`;
}
if (chartPadding.left) {
const leftPercent = (chartPadding.left / safeWidth) * 100;
return `${layout.totalBase.left + leftPercent * 0.25}%`;
}
if (isHalf && (halfType === 'left' || halfType === 'right')) {
return `${layout.totalBase.left}%`;
}
return 'center';
};
// The pie origin is the natural spot for the "Total" text (the middle of
// the hole, or the flat edge of a half donut), but for narrow arcs it can
// fall far outside the drawn shape, even off-canvas. Clamp it into the
// arc's bounding box, which always sits within the rect.
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const halfBoxWidth = (boxWidth / 2) * outerPx;
const halfBoxHeight = (boxHeight / 2) * outerPx;
const anchorX = clamp(
centerX,
rectWidth / 2 - halfBoxWidth,
rectWidth / 2 + halfBoxWidth,
);
const anchorY = clamp(
centerY,
rectHeight / 2 - halfBoxHeight,
rectHeight / 2 + halfBoxHeight,
);
return {
top: calculateTop(),
left: calculateLeft(),
center: [round(centerX), round(centerY)],
radius: [
`${round(donut ? innerRadius * scale : 0)}%`,
`${round(outerRadius * scale)}%`,
],
totalAnchor: {
x: round(padding.left + anchorX),
y: round(padding.top + anchorY),
},
};
}
@@ -478,7 +508,16 @@ export default function transformProps(
effectiveLegendMargin,
);
const { centerOffset } = getHalfDonutLayout(startAngle, sweptAngle);
const pieLayout = getPieLayout({
width,
height,
padding: chartPadding,
startAngle,
sweptAngle,
donut,
innerRadius,
outerRadius,
});
const series: PieSeriesOption[] = [
{
@@ -486,8 +525,8 @@ export default function transformProps(
...chartPadding,
animation: false,
roseType: roseType || undefined,
radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`],
center: [`${50 + centerOffset.x}%`, `${50 + centerOffset.y}%`],
radius: pieLayout.radius,
center: pieLayout.center,
startAngle,
endAngle: startAngle - sweptAngle,
avoidLabelOverlap: true,
@@ -550,16 +589,18 @@ export default function transformProps(
graphic: showTotal
? {
type: 'text',
...getTotalValuePadding({
chartPadding,
donut,
width,
height,
startAngle,
sweptAngle,
}),
// Donut: center the text on the pie origin (the middle of the
// hole, or the flat edge of a partial arc). Pie: park it at the
// top center of the padded rect so it doesn't overlap the slices.
x: donut
? pieLayout.totalAnchor.x
: chartPadding.left +
(width - chartPadding.left - chartPadding.right) / 2,
y: donut ? pieLayout.totalAnchor.y : chartPadding.top,
style: {
text: t('Total: %s', numberFormatter(totalValue)),
align: 'center',
verticalAlign: donut ? 'middle' : 'top',
fontSize: 16,
fontWeight: 'bold',
fill: theme.colorText,

View File

@@ -103,36 +103,3 @@ export interface PieChartDataItem {
};
isOther?: boolean;
}
interface ChartPadding {
top: number;
bottom: number;
left: number;
right: number;
}
export interface TotalValuePaddingProps {
chartPadding: ChartPadding;
donut: boolean;
width: number;
height: number;
sweptAngle: number;
startAngle: number;
}
export interface PaddingResult {
top?: string;
left?: string;
}
/**
* Semicircular chart layout type.
*
* - `'top'` — arc at the top, center shifted downwards.
* - `'bottom'` — arc at the bottom, center shifted upwards.
* - `'left'` — arc on the left, center shifted to the right.
* - `'right'` — arc on the right, center shifted to the left.
* - `'none'` — full circle (no recentering).
* @see getHalfDonut
*/
export type HalfDonut = 'top' | 'bottom' | 'left' | 'right' | 'none';

View File

@@ -1161,3 +1161,83 @@ test('x-axis dedup keeps the forced min label when the endpoints format identica
expect(formatter(min)).toBe('May');
});
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
// Regression test for https://github.com/apache/superset/issues/37921
// ("Residual" follow-up to #37055).
//
// When Query A has multiple metrics + at least one Group By dimension,
// the display-name builder in transformProps.ts used to prepend the FIRST
// metric's display name to every series that didn't literally contain it:
// name: `${MetricDisplayNameA}, ${entryName}`
// For series belonging to the *second* metric, this produced a
// cross-contaminated label like `score_one, score_two, A` — the
// user-visible "first metric duplicated" symptom in the legend / tooltip.
// The fix derives each series' metric from its label-map tuple instead.
const multiMetricRows = [
{
'score_one, A': 1,
'score_one, B': 2,
'score_two, A': 3,
'score_two, B': 4,
ds: 599616000000,
},
{
'score_one, A': 5,
'score_one, B': 6,
'score_two, A': 7,
'score_two, B': 8,
ds: 599916000000,
},
];
const multiMetricLabelMap = {
ds: ['ds'],
'score_one, A': ['score_one', 'A'],
'score_one, B': ['score_one', 'B'],
'score_two, A': ['score_two', 'A'],
'score_two, B': ['score_two', 'B'],
};
const queryAData = createTestQueryData(multiMetricRows, {
label_map: multiMetricLabelMap,
});
// Query B keeps the existing single-metric shape — the bug is on
// Query A's path so we just need a valid Query B alongside.
const queryBData = createTestQueryData(defaultQueryRows, {
label_map: defaultLabelMap,
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryAData, queryBData],
formData: {
...formData,
metrics: ['score_one', 'score_two'],
groupby: ['category'],
},
queriesData: [queryAData, queryBData],
});
const transformed = transformProps(chartProps);
const queryASeriesNames = (transformed.echartOptions.series as any[])
.map((s: any) => String(s.name))
.filter((n: string) => n.includes('score_'));
// Each (metric, dim_value) combo from Query A should appear exactly once
// with the *correct* metric prefix — not the first-metric-prepended-to-
// everything-else form. Comparing the sorted array (rather than using
// separate toContain assertions) also catches a regression that emits
// duplicate series for the same name.
expect([...queryASeriesNames].sort()).toEqual(
['score_one, A', 'score_one, B', 'score_two, A', 'score_two, B'].sort(),
);
// And explicitly: no series name should contain *both* metric names —
// that's the smoking gun for the duplication bug.
for (const name of queryASeriesNames) {
expect(name).not.toMatch(/score_one,\s+score_two/);
}
});

View File

@@ -29,12 +29,20 @@ import type {
} from 'echarts/types/src/util/types';
import transformProps, {
parseParams,
getHalfDonut,
getTotalValuePadding,
getArcBoundingBox,
getPieLayout,
} from '../../src/Pie/transformProps';
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
import { LegendOrientation, LegendType } from '../../src/types';
const getGraphic = (transformed: ReturnType<typeof transformProps>) =>
transformed.echartOptions.graphic as {
type: string;
x: number;
y: number;
style: { text: string; align: string; verticalAlign: string };
};
describe('Pie transformProps', () => {
const formData: SqlaFormData = {
colorScheme: 'bnbColors',
@@ -352,109 +360,60 @@ describe('Total value positioning with legends', () => {
test('should center total text when legend is on the right', () => {
const props = getChartPropsWithLegend(true, true, 'right', true);
const transformed = transformProps(props);
const graphic = getGraphic(transformProps(props));
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
top: 'middle',
style: expect.objectContaining({
text: expect.stringContaining('Total:'),
}),
}),
);
// The left position should be less than 50% (shifted left)
const leftValue = parseFloat(
(transformed.echartOptions.graphic as any).left.replace('%', ''),
);
expect(leftValue).toBeLessThan(50);
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
expect(graphic.type).toBe('text');
expect(graphic.style.text).toContain('Total:');
expect(graphic.style.align).toBe('center');
expect(graphic.style.verticalAlign).toBe('middle');
// Anchored on the pie origin, which shifts left of the container center
// because the right legend narrows the series rect.
expect(graphic.x).toBeLessThan(400);
expect(graphic.y).toBe(300);
});
test('should center total text when legend is on the left', () => {
const props = getChartPropsWithLegend(true, true, 'left', true);
const transformed = transformProps(props);
const graphic = getGraphic(transformProps(props));
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
top: 'middle',
}),
);
// The left position should be greater than 50% (shifted right)
const leftValue = parseFloat(
(transformed.echartOptions.graphic as any).left.replace('%', ''),
);
expect(leftValue).toBeGreaterThan(50);
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
// The left legend pads the rect, pushing the pie origin right.
expect(graphic.x).toBeGreaterThan(400);
expect(graphic.y).toBe(300);
});
test('should center total text when legend is on top', () => {
const props = getChartPropsWithLegend(true, true, 'top', true);
const transformed = transformProps(props);
const graphic = getGraphic(transformProps(props));
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: 'center',
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
}),
);
// The top position should be adjusted for top legend
const topValue = parseFloat(
(transformed.echartOptions.graphic as any).top.replace('%', ''),
);
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
expect(graphic.x).toBe(400);
expect(graphic.y).toBeGreaterThan(300);
});
test('should center total text when legend is on bottom', () => {
const props = getChartPropsWithLegend(true, true, 'bottom', true);
const transformed = transformProps(props);
const graphic = getGraphic(transformProps(props));
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: 'center',
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
}),
);
// The top position should be adjusted for bottom legend
const topValue = parseFloat(
(transformed.echartOptions.graphic as any).top.replace('%', ''),
);
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
expect(graphic.x).toBe(400);
expect(graphic.y).toBeLessThan(300);
});
test('should use default positioning when no legend is shown', () => {
test('should center on the pie origin when no legend is shown', () => {
const props = getChartPropsWithLegend(true, false, 'right', true);
const transformed = transformProps(props);
const graphic = getGraphic(transformProps(props));
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: 'center',
top: 'middle',
}),
);
expect(graphic.x).toBe(400);
expect(graphic.y).toBe(300);
expect(graphic.style.verticalAlign).toBe('middle');
});
test('should handle regular pie chart (non-donut) positioning', () => {
test('should park total at the top of the rect for non-donut charts', () => {
const props = getChartPropsWithLegend(true, true, 'right', false);
const transformed = transformProps(props);
const graphic = getGraphic(transformProps(props));
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
top: '0', // Non-donut charts use '0' as default top position
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
}),
);
expect(graphic.y).toBe(0);
expect(graphic.style.verticalAlign).toBe('top');
// Horizontally centered over the narrowed rect, not the container.
expect(graphic.x).toBeLessThan(400);
});
test('should not show total graphic when showTotal is false', () => {
@@ -655,6 +614,7 @@ const getAngleChartProps = (
startAngle,
sweptAngle,
show_total: true,
show_legend: false,
};
return new ChartProps({
@@ -673,217 +633,110 @@ const getAngleChartProps = (
}) as EchartsPieChartProps;
};
test('sets center to 70% for half-donut', () => {
const props = getAngleChartProps(true, 180);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].center).toEqual(['50%', '70%']);
const getSeries = (props: EchartsPieChartProps) =>
transformProps(props).echartOptions.series as PieSeriesOption[];
test('keeps ECharts default layout for a full donut', () => {
const series = getSeries(getAngleChartProps(true, 360, 90));
expect(series[0].center).toEqual([400, 300]);
expect(series[0].radius).toEqual(['30%', '70%']);
});
test('keeps center at 50% for full donut', () => {
const props = getAngleChartProps(true, 360);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].center).toEqual(['50%', '50%']);
test('recenters and scales up a top half-donut', () => {
// Bounding box is 2 wide x 1 tall, so an 800x600 canvas fits a radius
// basis of min(800/2, 600/1) = 400 instead of 300: scale 4/3.
const series = getSeries(getAngleChartProps(true, 180, 180));
expect(series[0].center).toEqual([400, 440]);
expect(series[0].radius).toEqual(['40%', '93.33%']);
});
test('calculates endAngle for a quarter donut', () => {
const props = getAngleChartProps(true, 90);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].endAngle).toBe(90);
test('recenters a bottom half-donut upwards', () => {
const series = getSeries(getAngleChartProps(true, 180, 0));
expect(series[0].center).toEqual([400, 160]);
});
test('sets center to 30% for bottom half-donut (startAngle=0)', () => {
const props = getAngleChartProps(true, 180, 0);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].center).toEqual(['50%', '30%']);
test('recenters a right half-donut leftwards without scaling', () => {
// A lateral half is 1 wide x 2 tall; height binds at the full-circle
// basis, so the radius stays put and only the center shifts.
const series = getSeries(getAngleChartProps(true, 180, 90));
expect(series[0].center).toEqual([295, 300]);
expect(series[0].radius).toEqual(['30%', '70%']);
});
test('sets center to 30% for bottom half-donut (startAngle=360)', () => {
const props = getAngleChartProps(true, 180, 360);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].center).toEqual(['50%', '30%']);
test('recenters a left half-donut rightwards', () => {
const series = getSeries(getAngleChartProps(true, 180, 270));
expect(series[0].center).toEqual([505, 300]);
});
test('shifts center left for right half-donut (startAngle=90)', () => {
const props = getAngleChartProps(true, 180, 90);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].center).toEqual(['40%', '50%']);
test('recenters non-cardinal start angles too', () => {
const series = getSeries(getAngleChartProps(true, 180, 170));
expect(series[0].center).not.toEqual([400, 300]);
});
test('shifts center right for left half-donut (startAngle=270)', () => {
const props = getAngleChartProps(true, 180, 270);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].center).toEqual(['60%', '50%']);
test('scales a quarter donut to the fit cap', () => {
const series = getSeries(getAngleChartProps(true, 90, 180));
expect(series[0].center).toEqual([610, 510]);
expect(series[0].radius).toEqual(['60%', '140%']);
});
test('keeps center at 50% for non-cardinal start angle even when sweep ≤ 180', () => {
const props = getAngleChartProps(true, 180, 45);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
expect(series[0].center).toEqual(['50%', '50%']);
});
test('allows endAngle to go negative for right half-donut', () => {
const props = getAngleChartProps(true, 180, 90);
const transformed = transformProps(props);
const series = transformed.echartOptions.series as PieSeriesOption[];
test('passes startAngle through and derives endAngle from the sweep', () => {
const series = getSeries(getAngleChartProps(true, 180, 90));
expect(series[0].startAngle).toBe(90);
expect(series[0].endAngle).toBe(-90);
});
test('anchors the total on the pie origin for a top half-donut', () => {
const graphic = getGraphic(
transformProps(getAngleChartProps(true, 180, 180)),
);
expect(graphic.x).toBe(400);
expect(graphic.y).toBe(440);
});
test.each([
[180, 180, 'top'],
[180, 90, 'top'],
[180, 45, 'top'],
[0, 180, 'bottom'],
[360, 180, 'bottom'],
[360, 90, 'bottom'],
[90, 180, 'right'],
[90, 90, 'right'],
[270, 180, 'left'],
[270, 90, 'left'],
[45, 180, 'none'],
[170, 180, 'none'],
[180, 360, 'none'],
[180, 181, 'none'],
[0, 360, 'none'],
])('startAngle=%i, sweptAngle=%i → %s', (start, swept, expected) => {
expect(getHalfDonut(start, swept)).toBe(expected);
['full circle', 90, 360, 0.3, { minX: -1, maxX: 1, minY: -1, maxY: 1 }],
['top half', 180, 180, 0, { minX: -1, maxX: 1, minY: 0, maxY: 1 }],
['bottom half', 0, 180, 0, { minX: -1, maxX: 1, minY: -1, maxY: 0 }],
['right half', 90, 180, 0, { minX: 0, maxX: 1, minY: -1, maxY: 1 }],
['left half', 270, 180, 0, { minX: -1, maxX: 0, minY: -1, maxY: 1 }],
['top-left quarter', 180, 90, 0.5, { minX: -1, maxX: 0, minY: 0, maxY: 1 }],
])('getArcBoundingBox: %s', (_label, start, sweep, inner, expected) => {
const box = getArcBoundingBox(start, sweep, inner);
expect(box.minX).toBeCloseTo(expected.minX, 10);
expect(box.maxX).toBeCloseTo(expected.maxX, 10);
expect(box.minY).toBeCloseTo(expected.minY, 10);
expect(box.maxY).toBeCloseTo(expected.maxY, 10);
});
const baseProps = {
donut: true,
width: 800,
height: 600,
startAngle: 180,
sweptAngle: 360,
};
test('returns "middle" for donut without padding and not half', () => {
const result = getTotalValuePadding({
...baseProps,
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
});
expect(result.top).toBe('middle');
test('getArcBoundingBox includes inner arc endpoints for narrow donuts', () => {
// A 20-degree sliver straddling 12 o'clock: the lowest point of the
// annular sector is an inner endpoint, not an outer one.
const box = getArcBoundingBox(100, 20, 0.5);
expect(box.minY).toBeCloseTo(0.5 * Math.sin((80 * Math.PI) / 180), 10);
expect(box.maxY).toBeCloseTo(1, 10);
});
test('returns "0" for non-donut without padding and not half', () => {
const result = getTotalValuePadding({
...baseProps,
donut: false,
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
});
expect(result.top).toBe('0');
});
test('adjusts top for donut with bottom padding', () => {
const result = getTotalValuePadding({
...baseProps,
chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
});
expect(result.top).toBe('45%');
});
test('returns "0" for non-donut with bottom padding', () => {
const result = getTotalValuePadding({
...baseProps,
donut: false,
chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
});
expect(result.top).toBe('0');
});
test('adjusts top for donut with top padding', () => {
const result = getTotalValuePadding({
...baseProps,
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
});
expect(result.top).toBe('55%');
});
test('adjusts top for non-donut with top padding', () => {
const result = getTotalValuePadding({
...baseProps,
donut: false,
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
});
expect(result.top).toBe('10%');
});
test('positions total at 68.5% for top half-donut without padding', () => {
const result = getTotalValuePadding({
...baseProps,
sweptAngle: 180,
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
});
expect(result.top).toBe('68.5%');
});
test('adjusts total position from 68.5% base for top half-donut with top padding', () => {
const result = getTotalValuePadding({
...baseProps,
sweptAngle: 180,
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
});
expect(result.top).toBe('73.5%');
});
test('returns "center" when no left/right padding', () => {
const result = getTotalValuePadding({
...baseProps,
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
});
expect(result.left).toBe('center');
});
test('adjusts left for left padding', () => {
const result = getTotalValuePadding({
...baseProps,
chartPadding: { top: 0, bottom: 0, left: 80, right: 0 },
});
expect(result.left).toBe('52.5%');
});
test('adjusts left for right padding', () => {
const result = getTotalValuePadding({
...baseProps,
chartPadding: { top: 0, bottom: 0, left: 0, right: 80 },
});
expect(result.left).toBe('42.5%');
});
test('prioritizes right padding over left padding', () => {
const result = getTotalValuePadding({
...baseProps,
chartPadding: { top: 0, bottom: 0, left: 80, right: 80 },
});
expect(result.left).toBe('42.5%');
});
test('positions total inside the shifted center for left half-donut', () => {
const result = getTotalValuePadding({
...baseProps,
startAngle: 270,
sweptAngle: 180,
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
});
expect(result.left).toBe('55%');
expect(result.top).toBe('50%');
});
test('positions total inside the shifted center for right half-donut', () => {
const result = getTotalValuePadding({
...baseProps,
test('getPieLayout centers the bounding box within legend padding', () => {
const layout = getPieLayout({
width: 800,
height: 600,
padding: { top: 0, bottom: 0, left: 0, right: 200 },
startAngle: 90,
sweptAngle: 180,
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
sweptAngle: 360,
donut: true,
innerRadius: 30,
outerRadius: 70,
});
expect(result.left).toBe('35%');
expect(result.top).toBe('50%');
// Rect is 600x600; the pie centers within it and the total anchor is
// reported in container coordinates.
expect(layout.center).toEqual([300, 300]);
expect(layout.totalAnchor).toEqual({ x: 300, y: 300 });
});
test('clamps the total anchor into the arc box for narrow arcs', () => {
// A 20-degree sliver's pie origin falls far below the drawn wedge; the
// anchor must stay within the arc's bounding box so the text is visible.
const graphic = getGraphic(transformProps(getAngleChartProps(true, 20, 100)));
expect(graphic.x).toBe(400);
expect(graphic.y).toBeCloseTo(421.37, 1);
});

View File

@@ -713,8 +713,8 @@ export function handleChartDataResponse(
// Query is running asynchronously and we must await the results.
// When status is 202, result contains async event data (job_id, channel_id, etc.)
// which differs from QueryData. We cast through unknown to handle this safely.
// The optional signal lets a caller (e.g. StatefulChart) cancel the wait
// when its chart is superseded or unmounted, avoiding leaked listeners.
// The optional signal lets a caller abort the wait (Stop pressed, chart
// superseded or unmounted), cancelling the job and avoiding leaked listeners.
if (useLegacyApi) {
return waitForAsyncData(
result[0] as unknown as Parameters<typeof waitForAsyncData>[0],
@@ -786,15 +786,25 @@ export function exploreJSON(
const [useLegacyApi] = getQuerySettings(formData);
const chartDataRequestCaught = chartDataRequest
.then(({ response, json }) =>
handleChartDataResponse(response, json, useLegacyApi),
handleChartDataResponse(
response,
json,
useLegacyApi,
controller.signal,
),
)
.then(queriesResponse => {
// Drop stale responses: if a newer query has started for this chart,
// its controller will have replaced ours in state, so ignore this
// response to avoid clobbering newer data with older results.
// Drop stale responses: if this request was aborted (Stop, or a newer
// query that aborted ours), or a newer query has since replaced our
// controller in state, ignore the result so we don't clobber newer
// data or a 'stopped' status. Checking the signal is authoritative
// because the reducer nulls out queryController when a query stops.
if (key != null) {
const currentController = getState().charts?.[key]?.queryController;
if (currentController && currentController !== controller) {
if (
controller.signal.aborted ||
(currentController != null && currentController !== controller)
) {
return undefined;
}
}
@@ -853,7 +863,10 @@ export function exploreJSON(
// so a slow earlier request can't mark a newer one as failed.
if (key != null) {
const currentController = getState().charts?.[key]?.queryController;
if (currentController && currentController !== controller) {
if (
controller.signal.aborted ||
(currentController != null && currentController !== controller)
) {
return undefined;
}
}

View File

@@ -47,6 +47,10 @@ export const URL_PARAMS = {
name: 'show_filters',
type: 'boolean',
},
showDownload: {
name: 'show_download',
type: 'boolean',
},
expandFilters: {
name: 'expand_filters',
type: 'boolean',

View File

@@ -547,7 +547,7 @@ test('Clear All stages filter_select clear without dispatching until Apply', asy
});
expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, {
id: filterId,
filterState: { value: undefined, validateStatus: undefined },
filterState: { value: null, validateStatus: undefined },
extraFormData: {},
});
updateDataMaskSpy.mockRestore();
@@ -685,12 +685,95 @@ test('Clear All + Apply only dispatches for filters present in dataMask', async
expect(updateDataMaskSpy).toHaveBeenCalledTimes(1);
expect(updateDataMaskSpy).toHaveBeenCalledWith(idInMask, {
id: idInMask,
filterState: { value: undefined, validateStatus: undefined },
filterState: { value: null, validateStatus: undefined },
extraFormData: {},
});
updateDataMaskSpy.mockRestore();
});
test('Clear All in horizontal bar does not re-apply default values', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data',
{
result: [
{
data: [{ test_column: 'East' }, { test_column: 'West' }],
colnames: ['test_column'],
coltypes: [1],
},
],
},
{ name: 'horizontal-clear-chart-data' },
);
const filterId = 'NATIVE_FILTER-horizontal-default';
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
const filterWithDefault = createFilter({
id: filterId,
name: 'Region',
filterType: 'filter_select',
targets: [{ datasetId: 7, column: { name: 'test_column' } }],
defaultDataMask: {
filterState: { value: ['East'] },
extraFormData: {
filters: [{ col: 'test_column', op: 'IN', val: ['East'] }],
},
},
chartsInScope: [18],
});
const stateHorizontal = {
...stateWithoutNativeFilters,
dashboardInfo: {
id: 1,
dash_edit_perm: true,
filterBarOrientation: FilterBarOrientation.Horizontal,
metadata: {
native_filter_configuration: [filterWithDefault],
chart_configuration: {},
},
},
dashboardState: {
...stateWithoutNativeFilters.dashboardState,
activeTabs: ['ROOT_ID'],
},
dataMask: {
[filterId]: createDataMask(filterId, ['East'], {
filters: [{ col: 'test_column', op: 'IN', val: ['East'] }],
}),
},
nativeFilters: {
filters: { [filterId]: filterWithDefault },
filtersState: {},
},
};
render(<FilterBar orientation={FilterBarOrientation.Horizontal} />, {
initialState: stateHorizontal,
useDnd: true,
useRedux: true,
useRouter: true,
});
await act(async () => {
jest.advanceTimersByTime(1000);
});
const clearBtn = screen.getByTestId(getTestId('clear-button'));
expect(clearBtn).not.toBeDisabled();
await act(async () => {
userEvent.click(clearBtn);
});
// Let the clear-all trigger round-trip through the filter plugin
await act(async () => {
jest.advanceTimersByTime(1000);
});
// The staged clear must survive the trigger completing: the default value
// must not be re-applied and Apply must stay enabled
expect(updateDataMaskSpy).not.toHaveBeenCalled();
expect(screen.queryByTitle('East')).not.toBeInTheDocument();
expect(screen.getByTestId(getTestId('apply-button'))).not.toBeDisabled();
updateDataMaskSpy.mockRestore();
});
test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones', async () => {
const inScopeFilterId = 'NATIVE_FILTER-in-scope';
const outOfScopeRequiredFilterId = 'NATIVE_FILTER-out-of-scope-required';
@@ -831,7 +914,7 @@ test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones',
expect(updateDataMaskSpy).toHaveBeenCalledWith(inScopeFilterId, {
id: inScopeFilterId,
filterState: { value: undefined, validateStatus: undefined },
filterState: { value: null, validateStatus: undefined },
extraFormData: {},
});

View File

@@ -517,22 +517,21 @@ const FilterBar: FC<FiltersBarProps> = ({
// Only clear in-scope filters
if (!inScopeFilterIds.has(id)) return;
// Range filters use [null, null] as the cleared value; others use undefined
const clearedValue =
filterType === 'filter_range' ? [null, null] : undefined;
// Cleared values stage as explicit null ([null, null] for ranges), never
// undefined: the select plugin's init effect treats undefined as
// "uninitialized" and would re-apply default values once the clear-all
// trigger completes.
const clearedValue = filterType === 'filter_range' ? [null, null] : null;
const isRequired = !!filter.controlValues?.enableEmptyFilter;
if (dataMaskSelected[id]) {
// Stage the cleared value locally; do NOT dispatch to Redux here.
// Persistence happens when the user clicks Apply.
setDataMaskSelected(draft => {
if (draft[id].filterState?.value !== undefined) {
draft[id].filterState!.value = clearedValue;
}
draft[id].extraFormData = {};
if (draft[id].filterState) {
draft[id].filterState!.validateStatus = isRequired
? 'error'
: undefined;
const { filterState } = draft[id];
if (filterState) {
filterState.value = clearedValue;
filterState.validateStatus = isRequired ? 'error' : undefined;
}
});
newClearAllTriggers[id] = true;

View File

@@ -31,6 +31,15 @@ import {
import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
jest.mock('./StandaloneDownloadControl', () => ({
__esModule: true,
default: () => (
<button type="button" data-test="standalone-download-button">
Download
</button>
),
}));
// Cast to accept partial mock props in tests
const ChartContainer = ChartContainerComponent as unknown as React.FC<
Record<string, any>
@@ -75,6 +84,10 @@ const createProps = (overrides = {}) => ({
describe('ChartContainer', () => {
jest.setTimeout(10000);
afterEach(() => {
window.history.replaceState({}, '', window.location.pathname);
});
test('renders when vizType is line', () => {
const props = createProps();
expect(isValidElement(<ChartContainer {...props} />)).toBe(true);
@@ -188,4 +201,77 @@ describe('ChartContainer', () => {
expect(await screen.findByRole('timer')).toBeInTheDocument();
expect(gutter).not.toBeVisible();
});
test('does not render standalone download control when show_download is absent', () => {
const props = createProps({
standalone: true,
can_download: true,
});
render(<ChartContainer {...props} />, { useRedux: true });
expect(
screen.queryByTestId('standalone-download-button'),
).not.toBeInTheDocument();
});
test('does not render standalone download control when show_download is disabled', () => {
window.history.replaceState({}, '', '?show_download=0');
const props = createProps({
standalone: true,
can_download: true,
});
render(<ChartContainer {...props} />, { useRedux: true });
expect(
screen.queryByTestId('standalone-download-button'),
).not.toBeInTheDocument();
});
test('renders standalone download control when show_download is enabled and user can download', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
standalone: true,
can_download: true,
});
render(<ChartContainer {...props} />, { useRedux: true });
expect(
screen.getByTestId('standalone-download-button'),
).toBeInTheDocument();
});
test('does not render standalone download control when user cannot download', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
standalone: true,
can_download: false,
});
render(<ChartContainer {...props} />, { useRedux: true });
expect(
screen.queryByTestId('standalone-download-button'),
).not.toBeInTheDocument();
});
test('does not render standalone download control outside standalone mode', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
standalone: false,
can_download: true,
});
render(<ChartContainer {...props} />, { useRedux: true });
expect(
screen.queryByTestId('standalone-download-button'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,147 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import { VizType } from '@superset-ui/core';
import { ExportStatus } from 'src/components/StreamingExportModal/StreamingExportModal';
import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport';
import StandaloneDownloadControl from './StandaloneDownloadControl';
jest.mock('../useExploreAdditionalActionsMenu/useExploreDataExport', () => ({
useExploreDataExport: jest.fn(),
}));
const mockUseExploreDataExport = useExploreDataExport as jest.MockedFunction<
typeof useExploreDataExport
>;
const exportCSV = jest.fn();
const exportJson = jest.fn();
const exportExcel = jest.fn();
const streamingExportState = {
isVisible: false,
progress: {
rowsProcessed: 0,
totalRows: undefined,
totalSize: 0,
speed: 0,
mbPerSecond: 0,
elapsedTime: 0,
status: ExportStatus.STREAMING,
},
onCancel: jest.fn(),
onRetry: jest.fn(),
onDownload: jest.fn(),
};
const latestQueryFormData = {
viz_type: VizType.Histogram,
datasource: '49__table',
};
describe('StandaloneDownloadControl', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseExploreDataExport.mockReturnValue({
exportCSV,
exportCSVPivoted: jest.fn(),
exportJson,
exportExcel,
handleExportError: jest.fn(),
streamingExportState,
});
});
test('renders the download button', () => {
render(
<StandaloneDownloadControl
latestQueryFormData={latestQueryFormData}
canDownload
/>,
{ useRedux: true },
);
expect(
screen.getByTestId('standalone-download-button'),
).toBeInTheDocument();
});
test('renders export options when download button is clicked', async () => {
render(
<StandaloneDownloadControl
latestQueryFormData={latestQueryFormData}
canDownload
/>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('standalone-download-button'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(screen.getByText('Export to .JSON')).toBeInTheDocument();
expect(screen.getByText('Export to Excel')).toBeInTheDocument();
});
test('exports CSV when CSV option is clicked', async () => {
render(
<StandaloneDownloadControl
latestQueryFormData={latestQueryFormData}
canDownload
/>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('standalone-download-button'));
userEvent.click(await screen.findByText('Export to .CSV'));
expect(exportCSV).toHaveBeenCalledTimes(1);
});
test('exports JSON when JSON option is clicked', async () => {
render(
<StandaloneDownloadControl
latestQueryFormData={latestQueryFormData}
canDownload
/>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('standalone-download-button'));
userEvent.click(await screen.findByText('Export to .JSON'));
expect(exportJson).toHaveBeenCalledTimes(1);
});
test('exports Excel when Excel option is clicked', async () => {
render(
<StandaloneDownloadControl
latestQueryFormData={latestQueryFormData}
canDownload
/>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('standalone-download-button'));
userEvent.click(await screen.findByText('Export to Excel'));
expect(exportExcel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,108 @@
/**
* 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 { useMemo } from 'react';
import { JsonObject, LatestQueryFormData } from '@superset-ui/core';
import { Button, Dropdown } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { t } from '@apache-superset/core/translation';
import { css, useTheme } from '@apache-superset/core/theme';
import { StreamingExportModal } from 'src/components/StreamingExportModal';
import { Slice } from 'src/types/Chart';
import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport';
interface StandaloneDownloadControlProps {
latestQueryFormData: LatestQueryFormData;
canDownload: boolean;
slice?: Slice;
ownState?: JsonObject;
}
const StandaloneDownloadControl = ({
latestQueryFormData,
canDownload,
slice,
ownState,
}: StandaloneDownloadControlProps) => {
const theme = useTheme();
const { exportCSV, exportJson, exportExcel, streamingExportState } =
useExploreDataExport({
latestQueryFormData,
canDownloadCSV: canDownload,
slice,
ownState,
});
const downloadMenuItems = useMemo(
() => [
{
key: 'export_csv',
label: t('Export to .CSV'),
onClick: exportCSV,
},
{
key: 'export_json',
label: t('Export to .JSON'),
onClick: exportJson,
},
{
key: 'export_excel',
label: t('Export to Excel'),
onClick: exportExcel,
},
],
[exportCSV, exportExcel, exportJson],
);
return (
<>
<Dropdown
trigger={['click']}
menu={{
selectable: false,
items: downloadMenuItems,
}}
>
<Button
aria-label={t('Download')}
data-test="standalone-download-button"
css={css`
position: absolute;
top: ${theme.sizeUnit * 2}px;
right: ${theme.sizeUnit * 2}px;
z-index: 1;
`}
>
<Icons.DownloadOutlined />
</Button>
</Dropdown>
<StreamingExportModal
visible={streamingExportState.isVisible}
onCancel={streamingExportState.onCancel}
onRetry={streamingExportState.onRetry}
onDownload={streamingExportState.onDownload}
progress={streamingExportState.progress}
/>
</>
);
};
export default StandaloneDownloadControl;

View File

@@ -32,6 +32,8 @@ import {
getExtensionsRegistry,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import ChartContainer from 'src/components/Chart/ChartContainer';
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
@@ -56,6 +58,7 @@ import { DataTablesPane } from '../DataTablesPane';
import { ChartPills } from '../ChartPills';
import { ExploreAlert } from '../ExploreAlert';
import useResizeDetectorByObserver from './useResizeDetectorByObserver';
import StandaloneDownloadControl from './StandaloneDownloadControl';
const extensionsRegistry = getExtensionsRegistry();
const DefaultHeader: React.FC<{ children?: React.ReactNode }> = ({
@@ -177,6 +180,7 @@ const ExploreChartPanel = ({
}: ExploreChartPanelProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const showDownload = getUrlParam(URL_PARAMS.showDownload);
const gutterMargin = theme.sizeUnit * GUTTER_SIZE_FACTOR;
const gutterHeight = theme.sizeUnit * GUTTER_SIZE_FACTOR;
@@ -552,7 +556,23 @@ const ExploreChartPanel = ({
document.body.className += ` ${standaloneClass}`;
}
return (
<div id="app" data-test="standalone-app">
<div
id="app"
data-test="standalone-app"
css={css`
position: relative;
height: 100%;
`}
>
{showDownload && canDownload && (
<StandaloneDownloadControl
latestQueryFormData={chart.latestQueryFormData}
canDownload={canDownload}
slice={slice}
ownState={mergedOwnState}
/>
)}
{standaloneChartBody}
</div>
);

View File

@@ -46,7 +46,6 @@ import {
} from '@superset-ui/core/components';
import { Menu, MenuProps } from '@superset-ui/core/components/Menu';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsPdf from 'src/utils/downloadAsPdf';
@@ -65,16 +64,14 @@ import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
} from 'src/logger/LogUtils';
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
import {
useStreamingExport,
StreamingProgress,
} from 'src/components/StreamingExportModal';
import { StreamingProgress } from 'src/components/StreamingExportModal';
import { Slice } from 'src/types/Chart';
import { ChartState, ExplorePageInitialData } from 'src/explore/types';
import { ReportObject } from 'src/features/reports/types';
import ViewQueryModal from '../controls/ViewQueryModal';
import EmbedCodeContent from '../EmbedCodeContent';
import { useDashboardsMenuItems } from './DashboardsSubMenu';
import { useExploreDataExport } from './useExploreDataExport';
export const SEARCH_THRESHOLD = 10;
@@ -317,11 +314,6 @@ export const useExploreAdditionalActionsMenu = (
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
);
const streamingThreshold = useSelector<ExploreState, number>(
state =>
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
);
const exploreChartState = useSelector<ExploreState, JsonObject | undefined>(
state => {
const chartKey = state.explore ? getChartKey(state.explore) : undefined;
@@ -362,32 +354,20 @@ export const useExploreAdditionalActionsMenu = (
);
// Streaming export state and handlers
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
const {
progress,
isExporting: _isExporting,
startExport,
cancelExport: _cancelExport,
resetExport,
retryExport,
} = useStreamingExport({
onComplete: () => {
// Don't show toast here - wait for user to click Download button
},
onError: () => {
addDangerToast(t('Export failed - please try again'));
},
exportCSV,
exportCSVPivoted,
exportJson,
exportExcel,
handleExportError,
streamingExportState,
} = useExploreDataExport({
latestQueryFormData,
canDownloadCSV,
slice,
ownState,
});
const handleCloseStreamingModal = useCallback(() => {
setIsStreamingModalVisible(false);
resetExport();
}, [resetExport]);
const handleDownloadComplete = useCallback(() => {
addSuccessToast(t('CSV file downloaded successfully'));
}, [addSuccessToast]);
// Use the updated report menu items hook
const reportMenuItem = useHeaderReportMenuItems({
chart,
@@ -439,159 +419,6 @@ export const useExploreAdditionalActionsMenu = (
}
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
const handleExportError = useCallback(
(error: unknown) => {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
addDangerToast(errorMessage);
}
},
[addDangerToast],
);
const exportCSV = useCallback(async () => {
if (!canDownloadCSV) return null;
// Determine row count for streaming threshold check
let actualRowCount;
const isTableViz = latestQueryFormData?.viz_type === 'table';
const queriesResponse = chart?.queriesResponse;
if (
isTableViz &&
queriesResponse &&
queriesResponse.length > 1 &&
queriesResponse[1]?.data?.[0]?.rowcount
) {
actualRowCount = queriesResponse[1].data[0].rowcount;
} else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
actualRowCount = queriesResponse[0].sql_rowcount;
} else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
actualRowCount = queriesResponse[0].rowcount;
} else {
actualRowCount = latestQueryFormData?.row_limit;
}
// Check if streaming should be used
const shouldUseStreaming =
actualRowCount && actualRowCount >= streamingThreshold;
let filename: string | undefined;
if (shouldUseStreaming) {
const now = new Date();
const date = now.toISOString().slice(0, 10);
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
const timestamp = `_${date}_${time}`;
const chartName =
slice?.slice_name || latestQueryFormData.viz_type || 'chart';
const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
filename = `${safeChartName}${timestamp}.csv`;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'full',
resultFormat: 'csv',
onStartStreamingExport: shouldUseStreaming
? exportParams => {
if (exportParams.url) {
setIsStreamingModalVisible(true);
startExport({
...exportParams,
url: exportParams.url,
filename,
expectedRows: actualRowCount,
exportType: exportParams.exportType as 'csv' | 'xlsx',
});
}
}
: null,
});
} catch (error) {
handleExportError(error);
}
return null;
}, [
canDownloadCSV,
latestQueryFormData,
ownState,
chart,
streamingThreshold,
slice,
startExport,
handleExportError,
]);
const exportCSVPivoted = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'post_processed',
resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportJson = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'json',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportExcel = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'xlsx',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const copyLink = useCallback(async () => {
try {
if (!latestQueryFormData?.datasource) {
@@ -1246,14 +1073,5 @@ export const useExploreAdditionalActionsMenu = (
canExportImage,
]);
// Return streaming modal state and handlers for parent to render
const streamingExportState = {
isVisible: isStreamingModalVisible,
progress,
onCancel: handleCloseStreamingModal,
onRetry: retryExport,
onDownload: handleDownloadComplete,
};
return [menu, isDropdownVisible, setIsDropdownVisible, streamingExportState];
};

View File

@@ -0,0 +1,223 @@
/**
* 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 { act, renderHook } from '@testing-library/react';
import * as exploreUtils from 'src/explore/exploreUtils';
import { useExploreDataExport } from './useExploreDataExport';
const mockAddDangerToast = jest.fn();
const mockAddSuccessToast = jest.fn();
const mockStartExport = jest.fn();
const mockResetExport = jest.fn();
const mockRetryExport = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
useToasts: () => ({
addDangerToast: mockAddDangerToast,
addSuccessToast: mockAddSuccessToast,
}),
}));
jest.mock('src/components/StreamingExportModal', () => ({
useStreamingExport: jest.fn(() => ({
progress: {
rowsProcessed: 0,
totalRows: undefined,
totalSize: 0,
speed: 0,
mbPerSecond: 0,
elapsedTime: 0,
status: 'streaming',
},
startExport: mockStartExport,
resetExport: mockResetExport,
retryExport: mockRetryExport,
})),
}));
jest.mock('src/explore/exploreUtils', () => ({
__esModule: true,
...jest.requireActual('src/explore/exploreUtils'),
exportChart: jest.fn(),
getChartKey: jest.fn(() => 1),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(callback =>
callback({
charts: {
1: {
queriesResponse: [{ sql_rowcount: 10 }],
},
},
explore: {
slice: {
slice_id: 1,
slice_name: 'Test Chart',
},
},
common: {
conf: {
CSV_STREAMING_ROW_THRESHOLD: 1000,
},
},
}),
),
}));
const mockExportChart = exploreUtils.exportChart as jest.Mock;
const defaultProps = {
latestQueryFormData: {
datasource: '1__table',
viz_type: 'table',
row_limit: 10000,
} as any,
canDownloadCSV: true,
slice: {
slice_id: 1,
slice_name: 'Test Chart',
} as any,
ownState: {},
};
beforeEach(() => {
jest.clearAllMocks();
mockExportChart.mockResolvedValue(undefined);
});
test('exports CSV with full result type', async () => {
const { result } = renderHook(() => useExploreDataExport(defaultProps));
await act(async () => {
await result.current.exportCSV();
});
expect(mockExportChart).toHaveBeenCalledWith(
expect.objectContaining({
formData: defaultProps.latestQueryFormData,
ownState: defaultProps.ownState,
resultType: 'full',
resultFormat: 'csv',
}),
);
});
test('exports pivoted CSV with post processed result type', async () => {
const { result } = renderHook(() => useExploreDataExport(defaultProps));
await act(async () => {
await result.current.exportCSVPivoted();
});
expect(mockExportChart).toHaveBeenCalledWith({
formData: defaultProps.latestQueryFormData,
ownState: defaultProps.ownState,
resultType: 'post_processed',
resultFormat: 'csv',
});
});
test('exports JSON with results result type', async () => {
const { result } = renderHook(() => useExploreDataExport(defaultProps));
await act(async () => {
await result.current.exportJson();
});
expect(mockExportChart).toHaveBeenCalledWith({
formData: defaultProps.latestQueryFormData,
ownState: defaultProps.ownState,
resultType: 'results',
resultFormat: 'json',
});
});
test('exports Excel with results result type', async () => {
const { result } = renderHook(() => useExploreDataExport(defaultProps));
await act(async () => {
await result.current.exportExcel();
});
expect(mockExportChart).toHaveBeenCalledWith({
formData: defaultProps.latestQueryFormData,
ownState: defaultProps.ownState,
resultType: 'results',
resultFormat: 'xlsx',
});
});
test('does not export when downloads are disabled', async () => {
const { result } = renderHook(() =>
useExploreDataExport({
...defaultProps,
canDownloadCSV: false,
}),
);
await act(async () => {
await result.current.exportCSV();
await result.current.exportCSVPivoted();
await result.current.exportJson();
await result.current.exportExcel();
});
expect(mockExportChart).not.toHaveBeenCalled();
});
test('shows large data error toast when export fails with status 413', async () => {
mockExportChart.mockRejectedValue({
status: 413,
});
const { result } = renderHook(() => useExploreDataExport(defaultProps));
await act(async () => {
await result.current.exportCSV();
});
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringContaining('too large to download'),
);
});
test('shows export error message when export fails', async () => {
mockExportChart.mockRejectedValue(new Error('Export exploded'));
const { result } = renderHook(() => useExploreDataExport(defaultProps));
await act(async () => {
await result.current.exportJson();
});
expect(mockAddDangerToast).toHaveBeenCalledWith('Export exploded');
});
test('exposes streaming export modal state and handlers', () => {
const { result } = renderHook(() => useExploreDataExport(defaultProps));
expect(result.current.streamingExportState).toEqual(
expect.objectContaining({
isVisible: false,
onRetry: mockRetryExport,
}),
);
});

View File

@@ -0,0 +1,275 @@
/**
* 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 { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import {
JsonObject,
LatestQueryFormData,
QueryFormData,
} from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import {
StreamingProgress,
useStreamingExport,
} from 'src/components/StreamingExportModal';
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
import { ChartState } from 'src/explore/types';
import { Slice } from 'src/types/Chart';
interface ExploreSlice {
slice?: Slice | null;
}
interface ExploreState {
charts?: Record<number, ChartState>;
explore?: ExploreSlice;
common?: {
conf?: {
CSV_STREAMING_ROW_THRESHOLD?: number;
};
};
}
export interface StreamingExportState {
isVisible: boolean;
progress: StreamingProgress;
onCancel: () => void;
onRetry: () => void;
onDownload: () => void;
}
interface UseExploreDataExportProps {
latestQueryFormData: LatestQueryFormData;
canDownloadCSV: boolean;
slice?: Slice | null;
ownState?: JsonObject;
}
export const useExploreDataExport = ({
latestQueryFormData,
canDownloadCSV,
slice,
ownState,
}: UseExploreDataExportProps) => {
const { addDangerToast, addSuccessToast } = useToasts();
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
);
const streamingThreshold = useSelector<ExploreState, number>(
state =>
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
);
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
const { progress, startExport, resetExport, retryExport } =
useStreamingExport({
onComplete: () => {
// Wait for the user to click Download before showing a success toast.
},
onError: () => {
addDangerToast(t('Export failed - please try again'));
},
});
const handleCloseStreamingModal = useCallback(() => {
setIsStreamingModalVisible(false);
resetExport();
}, [resetExport]);
const handleDownloadComplete = useCallback(() => {
addSuccessToast(t('CSV file downloaded successfully'));
}, [addSuccessToast]);
const handleExportError = useCallback(
(error: unknown) => {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
addDangerToast(errorMessage);
}
},
[addDangerToast],
);
const exportCSV = useCallback(async () => {
if (!canDownloadCSV) return null;
let actualRowCount;
const isTableViz = latestQueryFormData?.viz_type === 'table';
const queriesResponse = chart?.queriesResponse;
if (
isTableViz &&
queriesResponse &&
queriesResponse.length > 1 &&
queriesResponse[1]?.data?.[0]?.rowcount
) {
actualRowCount = queriesResponse[1].data[0].rowcount;
} else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
actualRowCount = queriesResponse[0].sql_rowcount;
} else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
actualRowCount = queriesResponse[0].rowcount;
} else {
actualRowCount = latestQueryFormData?.row_limit;
}
const shouldUseStreaming =
actualRowCount && actualRowCount >= streamingThreshold;
let filename: string | undefined;
if (shouldUseStreaming) {
const now = new Date();
const date = now.toISOString().slice(0, 10);
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
const timestamp = `_${date}_${time}`;
const chartName =
slice?.slice_name || latestQueryFormData.viz_type || 'chart';
const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
filename = `${safeChartName}${timestamp}.csv`;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'full',
resultFormat: 'csv',
onStartStreamingExport: shouldUseStreaming
? exportParams => {
if (exportParams.url) {
setIsStreamingModalVisible(true);
startExport({
...exportParams,
url: exportParams.url,
filename,
expectedRows: actualRowCount,
exportType: exportParams.exportType as 'csv' | 'xlsx',
});
}
}
: null,
});
} catch (error) {
handleExportError(error);
}
return null;
}, [
canDownloadCSV,
latestQueryFormData,
ownState,
chart,
streamingThreshold,
slice,
startExport,
handleExportError,
]);
const exportCSVPivoted = useCallback(async () => {
if (!canDownloadCSV) return null;
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'post_processed',
resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportJson = useCallback(async () => {
if (!canDownloadCSV) return null;
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'json',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportExcel = useCallback(async () => {
if (!canDownloadCSV) return null;
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'xlsx',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const streamingExportState: StreamingExportState = {
isVisible: isStreamingModalVisible,
progress,
onCancel: handleCloseStreamingModal,
onRetry: retryExport,
onDownload: handleDownloadComplete,
};
return {
exportCSV,
exportCSVPivoted,
exportJson,
exportExcel,
handleExportError,
streamingExportState,
};
};

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { AppSection } from '@superset-ui/core';
import { AppSection, type ChartProps } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
@@ -443,6 +443,35 @@ describe('RangeFilterPlugin', () => {
);
});
});
test('clears a filter still sitting at its default value', () => {
const renderWith = (value: [number | null, number | null]) => (
<RangeFilterPlugin
{...(transformProps({
...rangeProps,
filterState: { value },
} as unknown as ChartProps) as PluginFilterRangeProps)}
setDataMask={setDataMask}
/>
);
// The filter loads at its default and the user never touches it.
const { rerender } = render(renderWith([10, 70]));
setDataMask.mockClear();
// Clear All stages [null, null] for range filters.
rerender(renderWith([null, null]));
expect(setDataMask).toHaveBeenCalledWith({
extraFormData: {},
filterState: {
value: [null, null],
label: '',
validateStatus: undefined,
validateMessage: '',
},
});
});
});
test('calculateStep returns ~100 steps for integer ranges', () => {

View File

@@ -363,8 +363,14 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
return;
}
// Clear all case
if (filterState.value === undefined && !filterState.validateStatus) {
// Clear all case. The filter bar stages [null, null] for range filters, so
// matching only undefined let a filter still sitting at its default fall
// through to the default-restoring branch below.
if (
(filterState.value === undefined ||
isEqualArray(filterState.value, [null, null])) &&
!filterState.validateStatus
) {
setInputValue([null, null]);
updateDataMaskValue([null, null]);
return;

View File

@@ -134,6 +134,44 @@ describe('asyncEvent middleware', () => {
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(1);
});
test('rejects with an AbortError and cancels the job when the signal aborts', async () => {
const CANCEL_ENDPOINT = 'glob:*/api/v1/async_event/*/cancel';
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: {} });
const controller = new AbortController();
const promise = asyncEvent.waitForAsyncData(
asyncPendingEvent,
controller.signal,
);
controller.abort();
let error: any = null;
try {
await promise;
} catch (err) {
error = err;
}
expect(error?.name).toBe('AbortError');
// The cancel POST is fire-and-forget; let its microtask flush.
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);
});
test('rejects immediately when given an already-aborted signal', async () => {
const CANCEL_ENDPOINT = 'glob:*/api/v1/async_event/*/cancel';
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: {} });
const controller = new AbortController();
controller.abort();
await expect(
asyncEvent.waitForAsyncData(asyncPendingEvent, controller.signal),
).rejects.toMatchObject({ name: 'AbortError' });
// The cancel POST is fire-and-forget; let its microtask flush.
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);
});
test('rejects on event error status', async () => {
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, {

View File

@@ -104,6 +104,17 @@ const fetchCachedData = async (
return { status, data };
};
const cancelAsyncJob = (jobId: string) => {
// Best-effort server-side cancel; the request stops the running Celery task
// so it no longer consumes warehouse resources. Failures are non-fatal: the
// client has already stopped waiting on the job.
SupersetClient.post({
endpoint: `/api/v1/async_event/${jobId}/cancel`,
}).catch(error => {
logging.warn('Failed to cancel async job', jobId, error);
});
};
export const waitForAsyncData = async (
asyncResponse: AsyncEvent,
signal?: AbortSignal,
@@ -122,6 +133,7 @@ export const waitForAsyncData = async (
// Bail immediately if the caller has already aborted (e.g. the chart was
// unmounted before the job started), avoiding a leaked listener.
if (signal?.aborted) {
cancelAsyncJob(jobId);
reject(new DOMException('Aborted', 'AbortError'));
return;
}
@@ -161,11 +173,13 @@ export const waitForAsyncData = async (
}
};
// When the caller aborts (chart superseded/unmounted), stop listening so the
// listener and its retained closure don't leak and keep the poller busy.
// When the caller aborts (Stop pressed, chart superseded/unmounted), stop
// listening so the listener and its retained closure don't leak, and ask the
// server to cancel the job so it stops consuming warehouse resources.
if (signal) {
onAbort = () => {
cleanup();
cancelAsyncJob(jobId);
reject(new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });

View File

@@ -24,15 +24,15 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.1.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
"prettier": "^3.9.5",
"prettier": "^3.9.6",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.10"
},
"engines": {
@@ -402,9 +402,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -422,9 +419,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -442,9 +436,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -462,9 +453,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -482,9 +470,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -502,9 +487,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -741,17 +723,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
"integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/type-utils": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/type-utils": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -764,22 +746,176 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.64.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz",
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3"
},
"engines": {
@@ -794,15 +930,145 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz",
"integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==",
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.64.0",
"@typescript-eslint/types": "^8.64.0",
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
@@ -817,14 +1083,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz",
"integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0"
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -835,9 +1101,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz",
"integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -852,15 +1118,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
"integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -876,10 +1142,164 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -891,16 +1311,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz",
"integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.64.0",
"@typescript-eslint/tsconfig-utils": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -919,16 +1339,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz",
"integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0"
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -943,13 +1363,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz",
"integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -2075,9 +2495,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2099,9 +2516,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2123,9 +2537,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2147,9 +2558,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2511,9 +2919,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
@@ -2861,16 +3269,95 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz",
"integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz",
"integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.64.0",
"@typescript-eslint/parser": "8.64.0",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
"integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/type-utils": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.64.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz",
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0"
"@typescript-eslint/visitor-keys": "8.64.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
"integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@@ -33,15 +33,15 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.1.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
"prettier": "^3.9.5",
"prettier": "^3.9.6",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.10"
},
"engines": {

View File

@@ -15,14 +15,19 @@
# specific language governing permissions and limitations
# under the License.
import logging
import uuid
from flask import request, Response
from flask_appbuilder import expose
from flask_appbuilder.api import safe
from flask_appbuilder.security.decorators import permission_name, protect
from superset.async_events.async_query_manager import AsyncQueryTokenException
from superset.async_events.async_query_manager import (
AsyncQueryJobException,
AsyncQueryTokenException,
)
from superset.extensions import async_query_manager, event_logger
from superset.utils.core import get_user_id
from superset.views.base_api import BaseSupersetApi, statsd_metrics
logger = logging.getLogger(__name__)
@@ -99,3 +104,76 @@ class AsyncEventsRestApi(BaseSupersetApi):
return self.response_401()
return self.response(200, result=events)
@expose("/<job_id>/cancel", methods=("POST",))
@event_logger.log_this
@protect()
@safe
@statsd_metrics
@permission_name("cancel")
def cancel(self, job_id: str) -> Response:
"""Cancel a running async query job.
---
post:
summary: Cancel a running async query job
description: >-
Revokes the Celery task backing an in-flight async query. The
caller is authorized against the job's original owner (channel and
user), both resolved server-side from the request, so a client
cannot cancel a job it did not submit.
parameters:
- in: path
name: job_id
required: true
description: The job ID returned when the async query was submitted
schema:
type: string
responses:
200:
description: Job cancelled
content:
application/json:
schema:
type: object
properties:
result:
type: object
properties:
job_id:
type: string
status:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
try:
uuid.UUID(job_id)
except ValueError:
return self.response_400(message="Invalid job ID")
try:
async_channel_id = async_query_manager.parse_channel_id_from_request(
request
)
except AsyncQueryTokenException:
return self.response_401()
try:
async_query_manager.cancel_job(job_id, async_channel_id, get_user_id())
except AsyncQueryTokenException:
return self.response_403()
except AsyncQueryJobException:
return self.response_404()
return self.response(
200,
result={"job_id": job_id, "status": async_query_manager.STATUS_CANCELLED},
)

View File

@@ -106,6 +106,10 @@ class AsyncQueryManager:
STATUS_RUNNING = "running"
STATUS_ERROR = "error"
STATUS_DONE = "done"
STATUS_CANCELLED = "cancelled"
# Redis key prefix (within the GAQ stream namespace) for the per-job record
# that authorizes cancellation and flags a job as cancelled for the worker.
_JOB_REGISTRY_PREFIX = "job-cancel:"
def __init__(self) -> None:
super().__init__()
@@ -281,10 +285,32 @@ class AsyncQueryManager:
def init_job(self, channel_id: str, user_id: Optional[int]) -> dict[str, Any]:
job_id = str(uuid.uuid4())
self._register_cancellable_job(job_id, channel_id, user_id)
return build_job_metadata(
channel_id, job_id, user_id, status=self.STATUS_PENDING
)
def _job_registry_key(self, job_id: str) -> str:
return f"{self._stream_prefix}{self._JOB_REGISTRY_PREFIX}{job_id}"
def _register_cancellable_job(
self, job_id: str, channel_id: str, user_id: Optional[int]
) -> None:
"""
Persist the identity a later cancel request must match. Keyed by
``job_id`` (also the Celery task id — see ``submit_chart_data_job``) so
the cancel endpoint can authorize the caller against the job's original
owner without trusting the client-supplied id. Expires with the JWT so
it never outlives the job it guards.
"""
if not self._cache:
return
self._cache.set(
self._job_registry_key(job_id),
json.dumps({"channel_id": channel_id, "user_id": user_id}),
ex=self._jwt_expiration_seconds or None,
)
# pylint: disable=too-many-arguments
def submit_explore_json_job(
self,
@@ -307,6 +333,9 @@ class AsyncQueryManager:
response_type,
force,
],
# Use job_id as the Celery task id so the cancel endpoint can revoke
# the running task by the id the client already holds.
task_id=job_metadata["job_id"],
expires=self._jwt_expiration_seconds,
)
return job_metadata
@@ -332,6 +361,9 @@ class AsyncQueryManager:
else job_metadata,
form_data,
],
# Use job_id as the Celery task id so the cancel endpoint can revoke
# the running task by the id the client already holds.
task_id=job_metadata["job_id"],
expires=self._jwt_expiration_seconds,
)
return job_metadata
@@ -383,5 +415,88 @@ class AsyncQueryManager:
logger.debug("********** logging event data to stream %s", scoped_stream_name)
logger.debug(event_data)
# Drop the cancel record before announcing the result, so a cancel that
# arrives once the job is done finds nothing to flag and is reported as
# such instead of contradicting the event below. A cancelled job keeps
# its record until the TTL: the worker still has to recognize the
# SIGUSR1 it is about to receive as a cancellation.
if status in (self.STATUS_DONE, self.STATUS_ERROR):
if job_id := job_metadata.get("job_id"):
self._cache.delete(self._job_registry_key(job_id))
self._cache.xadd(scoped_stream_name, event_data, "*", self._stream_limit)
self._cache.xadd(full_stream_name, event_data, "*", self._stream_limit_firehose)
def is_job_cancelled(self, job_id: str) -> bool:
"""
Whether ``cancel_job`` has flagged this job for cancellation.
Called from the worker's exception handler, so any cache failure is
swallowed and treated as "not cancelled" — a Redis blip must never mask
the original error (e.g. a genuine timeout) with a connection error.
"""
if not self._cache:
return False
try:
raw = self._cache.get(self._job_registry_key(job_id))
if raw is None:
return False
return bool(json.loads(raw).get("cancelled"))
except Exception: # pylint: disable=broad-except
logger.warning(
"Failed to read cancellation flag for job %s", job_id, exc_info=True
)
return False
def cancel_job(self, job_id: str, channel_id: str, user_id: Optional[int]) -> None:
"""
Authorize and cancel a running async job.
The caller's ``channel_id`` and ``user_id`` (resolved server-side from
the request, never taken from the client) must match the job's original
owner. The terminal ``STATUS_CANCELLED`` event is emitted here rather
than by the worker, which never runs for a task revoked while it was
still queued; the worker only logs the cancellation it is told about
through the flag, so a job still gets exactly one terminal event.
:raises AsyncQueryJobException: the job is unknown or already terminal
:raises AsyncQueryTokenException: the caller does not own the job
"""
if not self._cache:
raise CacheBackendNotInitialized("Cache backend not initialized")
key = self._job_registry_key(job_id)
raw = self._cache.get(key)
if raw is None:
raise AsyncQueryJobException("Job not found or already completed")
record = json.loads(raw)
if record.get("channel_id") != channel_id or record.get("user_id") != user_id:
raise AsyncQueryTokenException("Not authorized to cancel this job")
# Flag before revoking so the worker's timeout handler, which may fire
# almost immediately, reliably sees the cancellation. Write only if the
# key still exists (``xx``): if the job finished and cleared its record
# between the read above and here, don't recreate a stale record or
# revoke a task that is already gone — report it as not found instead.
flagged = self._cache.set(
key,
json.dumps({**record, "cancelled": True}),
ex=self._jwt_expiration_seconds or None,
xx=True,
)
if not flagged:
raise AsyncQueryJobException("Job not found or already completed")
# pylint: disable=import-outside-toplevel
from superset.extensions import celery_app
# SIGUSR1 raises SoftTimeLimitExceeded inside the running task rather
# than hard-killing the process, so it unwinds through the task's
# exception handling instead of dying mid-query.
celery_app.control.revoke(job_id, terminate=True, signal="SIGUSR1")
self.update_job(
build_job_metadata(channel_id, job_id, user_id),
self.STATUS_CANCELLED,
)

View File

@@ -99,6 +99,15 @@ class RedisCacheBackend(RedisCache):
"""
return self._cache.set(name, value, ex=ex, px=px, nx=nx, xx=xx)
def get(self, name: str) -> Any:
"""
Get the raw value at key ``name``.
:param name: Key name
:returns: The stored value (bytes), or None if the key is absent
"""
return self._cache.get(name)
def delete(self, *names: str) -> int:
"""
Delete one or more keys.
@@ -283,6 +292,15 @@ class RedisSentinelCacheBackend(RedisSentinelCache):
"""
return self._cache.set(name, value, ex=ex, px=px, nx=nx, xx=xx)
def get(self, name: str) -> Any:
"""
Get the raw value at key ``name``.
:param name: Key name
:returns: The stored value (bytes), or None if the key is absent
"""
return self._cache.get(name)
def delete(self, *names: str) -> int:
"""
Delete one or more keys.

View File

@@ -318,7 +318,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
# another temporal filter. A new filter based on the value of
# the granularity will be added later in the code.
# In practice, this is replacing the previous default temporal filter.
if is_adhoc_column(filter_to_remove): # type: ignore
if filter_to_remove and is_adhoc_column(filter_to_remove): # type: ignore
filter_to_remove = filter_to_remove.get("sqlExpression")
if filter_to_remove:

View File

@@ -206,11 +206,9 @@ class QueryCacheManager:
)
query_cache.status = QueryStatus.SUCCESS
query_cache.is_loaded = True
query_cache.is_cached = cache_value is not None
query_cache.is_cached = True
query_cache.sql_rowcount = cache_value.get("sql_rowcount", None)
query_cache.cache_dttm = (
cache_value["dttm"] if cache_value is not None else None
)
query_cache.cache_dttm = cache_value["dttm"]
query_cache.queried_dttm = cache_value.get(
"queried_dttm", cache_value.get("dttm")
)

View File

@@ -397,7 +397,8 @@ class DashboardDAO(BaseDAO[Dashboard]):
md["color_namespace"] = data.get("color_namespace")
md["expanded_slices"] = data.get("expanded_slices", {})
md["refresh_frequency"] = data.get("refresh_frequency", 0)
if "refresh_frequency" in data:
md["refresh_frequency"] = data["refresh_frequency"]
md["color_scheme"] = data.get("color_scheme", "")
md["label_colors"] = data.get("label_colors", {})
md["shared_label_colors"] = data.get("shared_label_colors", [])

View File

@@ -534,6 +534,7 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
],
) -> list[SupersetError]:
errors: list[SupersetError] = []
connect_args: dict[str, Any] = {}
if extra := json.loads(properties.get("extra")): # type: ignore
engine_params = extra.get("engine_params", {})
connect_args = engine_params.get("connect_args", {})

View File

@@ -1235,6 +1235,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
all_columns: list[ResultSetColumnType] = []
expanded_columns = []
current_array_level = None
unnested_rows: dict[int, int] = defaultdict(int)
while to_process:
column, level = to_process.popleft()
if column["column_name"] not in [
@@ -1248,7 +1249,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
# added by the first. every time we change a level in the nested arrays
# we reinitialize this.
if level != current_array_level:
unnested_rows: dict[int, int] = defaultdict(int)
unnested_rows = defaultdict(int)
current_array_level = level
name = column["column_name"]

View File

@@ -261,6 +261,42 @@ class LoggingMiddleware(Middleware):
dataset_id = params.get("dataset_id")
return agent_id, user_id, dashboard_id, slice_id, dataset_id, params
def _extract_output_ids(self, result: ToolResult) -> tuple[int | None, int | None]:
"""Extract dashboard/chart IDs created by the tool from its response.
Create-style tools (generate_chart, generate_dashboard) don't take
chart_id/dashboard_id as input, so _extract_context_info never sees
them and every retry logs slice_id/dashboard_id=None even on the
attempt that actually persisted the object. Look at the response
body instead, since that's the only place the new ID appears.
Supports both flat ("chart_id"/"dashboard_id") and nested
("chart"/"dashboard" objects with an "id" field) response shapes.
"""
from superset.utils.json import loads as json_loads
try:
data = json_loads(result.content[0].text)
except (AttributeError, IndexError, ValueError, TypeError):
return None, None
if not isinstance(data, dict):
return None, None
slice_id = None
chart = data.get("chart")
if isinstance(chart, dict):
slice_id = chart.get("id")
if slice_id is None:
slice_id = data.get("chart_id")
dashboard_id = None
dashboard = data.get("dashboard")
if isinstance(dashboard, dict):
dashboard_id = dashboard.get("id")
if dashboard_id is None:
dashboard_id = data.get("dashboard_id")
return dashboard_id, slice_id
@staticmethod
def _resolve_tool_name(tool_name: str | None, params: Any) -> str | None:
"""Resolve the underlying tool name from call_tool proxy arguments.
@@ -283,6 +319,129 @@ class LoggingMiddleware(Middleware):
return params["name"]
return None
def _backfill_output_ids(
self,
success: bool,
result: Any,
dashboard_id: int | None,
slice_id: int | None,
) -> tuple[int | None, int | None]:
"""Fill in missing ids from a create tool's response on success.
Create-style tools (generate_chart, generate_dashboard) don't take
the new object's ID as input, so it's missing from params. On a
successful call, pull it from the response instead so retried
creates are distinguishable.
"""
if not success or not isinstance(result, ToolResult):
return dashboard_id, slice_id
output_dashboard_id, output_slice_id = self._extract_output_ids(result)
if dashboard_id is None:
dashboard_id = output_dashboard_id
if slice_id is None:
slice_id = output_slice_id
return dashboard_id, slice_id
@staticmethod
def _build_call_tool_payload(
*,
mcp_call_id: str,
tool_name: str | None,
agent_id: str | None,
params: Any,
method: str,
dashboard_id: int | None,
slice_id: int | None,
dataset_id: int | None,
success: bool,
mcp_tool: str | None,
error_type: str | None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"mcp_call_id": mcp_call_id,
"tool": tool_name,
"agent_id": agent_id,
"params": _sanitize_params(params),
"method": method,
"dashboard_id": dashboard_id,
"slice_id": slice_id,
"dataset_id": dataset_id,
"success": success,
}
if mcp_tool is not None:
payload["mcp_tool"] = mcp_tool
if error_type is not None:
payload["error_type"] = error_type
return payload
def _log_call_tool_result(
self,
*,
context: MiddlewareContext,
tool_name: str | None,
mcp_tool: str | None,
mcp_call_id: str,
agent_id: str | None,
user_id: int | None,
dashboard_id: int | None,
slice_id: int | None,
dataset_id: int | None,
params: Any,
success: bool,
error_type: str | None,
result: Any,
start_time: float,
) -> None:
duration_ms = int((time.time() - start_time) * 1000)
dashboard_id, slice_id = self._backfill_output_ids(
success, result, dashboard_id, slice_id
)
payload = self._build_call_tool_payload(
mcp_call_id=mcp_call_id,
tool_name=tool_name,
agent_id=agent_id,
params=params,
method=context.method,
dashboard_id=dashboard_id,
slice_id=slice_id,
dataset_id=dataset_id,
success=success,
mcp_tool=mcp_tool,
error_type=error_type,
)
if has_app_context():
event_logger.log(
user_id=user_id,
action="mcp_tool_call",
dashboard_id=dashboard_id,
duration_ms=duration_ms,
slice_id=slice_id,
referrer=None,
curated_payload=payload,
)
extra_parts = []
if mcp_tool is not None:
extra_parts.append(f"mcp_tool={mcp_tool}")
if error_type is not None:
extra_parts.append(f"error_type={error_type}")
extra = (", " + ", ".join(extra_parts)) if extra_parts else ""
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
"success=%s, mcp_call_id=%s%s",
tool_name,
agent_id,
user_id,
context.method,
dashboard_id,
slice_id,
dataset_id,
duration_ms,
success,
mcp_call_id,
extra,
)
async def on_call_tool(
self,
context: MiddlewareContext,
@@ -300,6 +459,7 @@ class LoggingMiddleware(Middleware):
start_time = time.time()
success = False
error_type: str | None = None
result: Any = None
try:
result = await call_next(context)
success = not self._is_error_response(result)
@@ -316,53 +476,21 @@ class LoggingMiddleware(Middleware):
success = False
raise
finally:
duration_ms = int((time.time() - start_time) * 1000)
payload: dict[str, Any] = {
"mcp_call_id": mcp_call_id,
"tool": tool_name,
"agent_id": agent_id,
"params": _sanitize_params(params),
"method": context.method,
"dashboard_id": dashboard_id,
"slice_id": slice_id,
"dataset_id": dataset_id,
"success": success,
}
if mcp_tool is not None:
payload["mcp_tool"] = mcp_tool
if error_type is not None:
payload["error_type"] = error_type
if has_app_context():
event_logger.log(
user_id=user_id,
action="mcp_tool_call",
dashboard_id=dashboard_id,
duration_ms=duration_ms,
slice_id=slice_id,
referrer=None,
curated_payload=payload,
)
extra_parts = []
if mcp_tool is not None:
extra_parts.append(f"mcp_tool={mcp_tool}")
if error_type is not None:
extra_parts.append(f"error_type={error_type}")
extra = (", " + ", ".join(extra_parts)) if extra_parts else ""
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
"success=%s, mcp_call_id=%s%s",
tool_name,
agent_id,
user_id,
context.method,
dashboard_id,
slice_id,
dataset_id,
duration_ms,
success,
mcp_call_id,
extra,
self._log_call_tool_result(
context=context,
tool_name=tool_name,
mcp_tool=mcp_tool,
mcp_call_id=mcp_call_id,
agent_id=agent_id,
user_id=user_id,
dashboard_id=dashboard_id,
slice_id=slice_id,
dataset_id=dataset_id,
params=params,
success=success,
error_type=error_type,
result=result,
start_time=start_time,
)
async def on_message(

View File

@@ -84,6 +84,28 @@ def _load_user_from_job_metadata(job_metadata: dict[str, Any]) -> User:
return user
def _handle_soft_time_limit(
job_metadata: dict[str, Any], ex: Exception, activity: str
) -> None:
"""
SoftTimeLimitExceeded is raised both by a genuine timeout and by a
user-initiated cancel (revoke sends SIGUSR1). The cancel endpoint has
already emitted the terminal event for the latter - it has to, since a task
revoked while still queued never reaches this handler - so only a timeout
is reported here, and without one the client would wait forever.
"""
if async_query_manager.is_job_cancelled(job_metadata["job_id"]):
logger.info("Cancelled by the user while %s", activity)
return
logger.warning("A timeout occurred while %s, error: %s", activity, ex)
async_query_manager.update_job(
job_metadata,
async_query_manager.STATUS_ERROR,
errors=[{"message": f"A timeout occurred while {activity}"}],
)
@celery_app.task(name="load_chart_data_into_cache", soft_time_limit=query_timeout)
def load_chart_data_into_cache(
job_metadata: dict[str, Any],
@@ -106,7 +128,7 @@ def load_chart_data_into_cache(
result_url=result_url,
)
except SoftTimeLimitExceeded as ex:
logger.warning("A timeout occurred while loading chart data, error: %s", ex)
_handle_soft_time_limit(job_metadata, ex, "loading chart data")
raise
except Exception as ex:
# Extract SIP-40 style errors when available
@@ -176,9 +198,7 @@ def load_explore_json_into_cache( # pylint: disable=too-many-locals
result_url=result_url,
)
except SoftTimeLimitExceeded as ex:
logger.warning(
"A timeout occurred while loading explore json, error: %s", ex
)
_handle_soft_time_limit(job_metadata, ex, "loading explore json")
raise
except Exception as ex:
if isinstance(ex, SupersetVizException):

View File

@@ -19,7 +19,7 @@ import logging
from collections.abc import Iterator
from datetime import datetime, timedelta
from croniter import croniter
from croniter import croniter, CroniterBadDateError
from flask import current_app
from pytz import timezone as pytz_timezone, UnknownTimeZoneError
@@ -42,8 +42,15 @@ def cron_schedule_window(
start_at = time_now - timedelta(seconds=window_size / 2)
stop_at = time_now + timedelta(seconds=window_size / 2)
crons = croniter(cron, start_at)
for schedule in crons.all_next(datetime):
if schedule >= stop_at:
break
# convert schedule back to utc
yield schedule.astimezone(utc).replace(tzinfo=None)
try:
for schedule in crons.all_next(datetime):
if schedule >= stop_at:
break
# convert schedule back to utc
yield schedule.astimezone(utc).replace(tzinfo=None)
except CroniterBadDateError:
logger.error(
"Cron schedule %s can never match a valid date; "
"it will not produce any executions",
cron,
)

File diff suppressed because it is too large Load Diff

View File

@@ -114,7 +114,7 @@ msgid " expression which needs to adhere to the "
msgstr "準拠すべき式"
msgid " for details."
msgstr "(詳細)"
msgstr "をご覧ください。"
#, python-format
msgid " near '%(highlight)s'"
@@ -196,7 +196,7 @@ msgstr "合計に対する %"
#, python-format
msgid "%(alertType)s \"%(alertName)s\" triggered successfully"
msgstr ""
msgstr "%(alertType)s 「%(alertName)s」が正常に実行されました"
#, python-format
msgid "%(dialect)s cannot be used as a data source for security reasons."
@@ -208,7 +208,7 @@ msgstr "%(label)s ファイル"
#, python-format
msgid "%(name)s.%(extension)s"
msgstr ""
msgstr "%(name)s.%(extension)s"
#, python-format
msgid "%(name)s.csv"
@@ -258,7 +258,7 @@ msgid ""
"Please recheck your query.\n"
"Exception: %(ex)s"
msgstr ""
"%(validator)s はクエリをチェックできませんでした。\n"
"%(validator)s はクエリを検証できませんでした。\n"
"クエリを再確認してください。\n"
"例外: %(ex)s"
@@ -504,7 +504,7 @@ msgid "+ %s more"
msgstr "他 %s 件"
msgid ", then paste the JSON below. See our"
msgstr "、次に以下のJSONを貼り付けてください。詳細当社の"
msgstr "で作成し、以下に JSON を貼り付けてください。詳細については、当社の"
msgid ""
"-- Note: Unless you save your query, these tabs will NOT persist if you "
@@ -848,10 +848,10 @@ msgstr "国別の値を表示可能な世界地図です。"
msgid ""
"A map that takes rendering circles with a variable radius at "
"latitude/longitude coordinates"
msgstr "緯度・経度座標に、半径が可変の円をレンダリングする地図です"
msgstr "緯度・経度座標に、可変半径の円を描画する地図です"
msgid "A metric to use for color"
msgstr "色に使用するメトリック"
msgstr "色付けに使用するメトリック"
msgid ""
"A multiplier applied to the point radius. Use this to uniformly scale all"
@@ -897,7 +897,7 @@ msgid ""
"Restore it via POST /api/v1/dataset/%(uuid)s/restore before creating a "
"new dataset over this table, or use a different table name."
msgstr ""
"ソフト削除されたデータセット (uuid %(uuid)s) "
"論理削除されたデータセット (uuid %(uuid)s) "
"が、すでにこのテーブルを参照しています。このテーブル上に新しいデータセットを作成する前に、POST "
"/api/v1/dataset/%(uuid)s/restore を使用して復元するか、別のテーブル名を使用してください。"
@@ -907,7 +907,7 @@ msgid ""
"Restore it via POST /api/v1/dataset/%(uuid)s/restore before uploading, or"
" upload to a different table name."
msgstr ""
"ソフト削除されたデータセットuuid %(uuid)sが、すでにこのテーブルを参照しています。アップロードする前に、POST "
"論理削除されたデータセットuuid %(uuid)sが、すでにこのテーブルを参照しています。アップロードする前に、POST "
"/api/v1/dataset/%(uuid)s/restore を使用して復元するか、別のテーブル名にアップロードしてください。"
msgid "A timeout occurred while executing the query."
@@ -920,7 +920,7 @@ msgid "A timeout occurred while generating a dataframe."
msgstr "データフレームの生成中にタイムアウトが発生しました。"
msgid "A timeout occurred while generating an Excel file."
msgstr ""
msgstr "Excel ファイルの生成中にタイムアウトが発生しました。"
msgid "A timeout occurred while taking a screenshot."
msgstr "スクリーンショットの撮影中にタイムアウトが発生しました。"
@@ -975,13 +975,13 @@ msgid "AUG"
msgstr "8月"
msgid "Aborted"
msgstr "中止"
msgstr "中止済み"
msgid "Aborting"
msgstr "中止中"
msgid "About"
msgstr "詳細"
msgstr "概要"
msgid "Access"
msgstr "アクセス"
@@ -993,16 +993,16 @@ msgid "Account"
msgstr "アカウント"
msgid "Action"
msgstr "アクション"
msgstr "操作"
msgid "Action Log"
msgstr "操作履歴"
msgstr "操作ログ"
msgid "Action Logs"
msgstr "操作履歴"
msgstr "操作ログ"
msgid "Actions"
msgstr "アクション"
msgstr "操作"
msgid "Active"
msgstr "有効"
@@ -1131,7 +1131,7 @@ msgid "Add dataset columns here to group the pivot table columns."
msgstr "ここにデータセットの列を追加して、ピボットテーブルの列をグループ化します。"
msgid "Add delivery method"
msgstr "信方法を追加"
msgstr "信方法を追加"
msgid "Add description of your tag"
msgstr "タグの説明を追加"
@@ -1201,7 +1201,7 @@ msgid "Add required control values to save chart"
msgstr "チャートを保存するために必要なコントロール値を追加してください"
msgid "Add service credentials"
msgstr ""
msgstr "サービスの認証情報を追加する"
msgid "Add sheet"
msgstr "シートを追加"
@@ -1267,10 +1267,10 @@ msgid ""
msgstr "比較値からの増減(正負の変化)に基づいて、チャートのシンボルに色を付けます。"
msgid "Adhoc metric SQL expression is invalid"
msgstr ""
msgstr "アドホックなメトリックのSQL式が無効です"
msgid "Adhoc metric aggregate is invalid"
msgstr ""
msgstr "アドホックなメトリックの集計が無効です"
msgid "Adjust how this database will interact with SQL Lab."
msgstr "このデータベースとSQL Labとの連携設定を調整します。"
@@ -1704,7 +1704,7 @@ msgstr "関数名の取得中にエラーが発生しました。"
#, python-format
msgid "An error occurred while fetching row level security subject values: %s"
msgstr ""
msgstr "行レベルのセキュリティ対象値の取得中にエラーが発生しました: %s"
#, python-format
msgid "An error occurred while fetching schema values: %s"
@@ -1805,7 +1805,7 @@ msgid "An error occurred while syncing permissions for %s: %s"
msgstr "%s の権限同期中にエラーが発生しました: %s"
msgid "An error occurred while triggering the report"
msgstr ""
msgstr "レポートの実行中にエラーが発生しました"
msgid "An error occurred while updating the extension."
msgstr "拡張機能の更新中にエラーが発生しました。"
@@ -1832,6 +1832,8 @@ msgid ""
"Angle at which the first slice begins, in degrees. 90° starts at the top,"
" 0°/360° at the right, 270° at the bottom, and 180° at the left."
msgstr ""
"最初のスライスを開始する角度。90°は上端から始まり、"
"0°/360°は右端から、270°は下端から、180°は左端から始まります。"
msgid "Angle at which to end progress axis"
msgstr "進行軸を終了する角度"
@@ -2067,7 +2069,7 @@ msgid "Are you sure you want to delete the selected layers?"
msgstr "選択したレイヤーを削除してもよろしいですか?"
msgid "Are you sure you want to delete the selected queries?"
msgstr ""
msgstr "選択したクエリを削除してもよろしいですか?"
msgid "Are you sure you want to delete the selected roles?"
msgstr "選択したロールを削除してもよろしいですか?"
@@ -2658,7 +2660,7 @@ msgid ""
"permanently once a purge capability ships, or remove the underlying rows "
"out-of-band, before deleting the database."
msgstr ""
"残っているデータセットがすべてソフト削除されているデータベースは削除できません。それらを復元POST "
"残っているデータセットがすべて論理削除されているデータベースは削除できません。それらを復元POST "
"/api/v1/dataset/<uuid>/restoreし、パージ機能が提供されたら完全に削除するか、データベースを削除する前に、別の手順で基となる行を削除してください。"
msgid "Cannot delete system themes"
@@ -2733,7 +2735,7 @@ msgid "Category, Value and Percentage"
msgstr "カテゴリ、値、割合 (%)"
msgid "Cell Padding"
msgstr "セルのパディング"
msgstr "セルの余白"
msgid "Cell Radius"
msgstr "セルの角の丸み"
@@ -2994,7 +2996,7 @@ msgid "Check out this dashboard: "
msgstr "このダッシュボードを確認: "
msgid "Check to force date partitions to have the same height"
msgstr "日付パーティションを強制的に同じ高さにする場合はオンにしてください"
msgstr "日付パーティションを強制的に同じ高さにする場合はチェックを入れます"
msgid "Child label position"
msgstr "子ラベルの位置"
@@ -3013,7 +3015,7 @@ msgid "Choose a chart for displaying on the map"
msgstr "マップ上に表示するチャートを選択"
msgid "Choose a chart or dashboard not both"
msgstr "チャートまたはダッシュボードのどちらか一方を選択してください"
msgstr "チャートまたはダッシュボードのどちらか一方のみを選択してください"
msgid "Choose a database..."
msgstr "データベースを選択..."
@@ -3163,7 +3165,7 @@ msgid "Clear local theme"
msgstr "ローカルテーマをクリア"
msgid "Clear search"
msgstr ""
msgstr "検索をクリア"
msgid "Clear the selection to revert to the system default theme"
msgstr "選択を解除して、システムの既定テーマに戻します"
@@ -3643,7 +3645,7 @@ msgid "Connection failed, please check your connection settings."
msgstr "接続に失敗しました。接続設定を確認してください。"
msgid "Connection looks good!"
msgstr ""
msgstr "正常に接続できました!"
msgid "Contains"
msgstr "含む"
@@ -3720,13 +3722,13 @@ msgid "Copy permalink to clipboard"
msgstr "固定リンクをクリップボードにコピー"
msgid "Copy query"
msgstr ""
msgstr "クエリをコピー"
msgid "Copy query URL"
msgstr ""
msgstr "クエリのURLをコピー"
msgid "Copy query link to your clipboard"
msgstr "クエリリンクをクリップボードにコピー"
msgstr "クエリリンクをクリップボードにコピー"
msgid "Copy the current data"
msgstr "現在のデータをコピー"
@@ -3844,7 +3846,7 @@ msgstr "新しいチャートを作成"
msgid ""
"Create a new tag and assign it to existing entities like charts or "
"dashboards"
msgstr ""
msgstr "新しいタグを作成し、チャートやダッシュボードなどの既存のエンティティに割り当てる"
msgid "Create and explore dataset"
msgstr "データセットを作成して探索"
@@ -4018,7 +4020,7 @@ msgid "Customize"
msgstr "カスタマイズ"
msgid "Customize Metrics"
msgstr "メトリックカスタマイズ"
msgstr "メトリックカスタマイズ"
msgid ""
"Customize cell titles using Handlebars template syntax. Available "
@@ -4026,7 +4028,7 @@ msgid ""
msgstr "Handlebarsテンプレート構文を使用して、セルの見出しをカスタマイズします。利用可能な変数{{rowLabel}}、{{colLabel}}"
msgid "Customize columns"
msgstr "列カスタマイズ"
msgstr "列カスタマイズ"
msgid "Customize data source, filters, and layout."
msgstr "データソース、フィルター、およびレイアウトのカスタマイズ。"
@@ -4047,10 +4049,10 @@ msgid ""
msgstr "合計値に対して、ツールチップ、凡例、および軸に表示するラベルをカスタマイズします。"
msgid "Customize tooltips template"
msgstr "ツールチップテンプレートカスタマイズ"
msgstr "ツールチップテンプレートカスタマイズ"
msgid "Cyclic dependency detected"
msgstr "循環参照(依存関係)が検出されました"
msgstr "循環依存(循環参照)が検出されました"
msgid "D3 format"
msgstr "D3形式"
@@ -4131,6 +4133,8 @@ msgid ""
"Dashboard cannot be restored because its slug is now used by another "
"active dashboard. Rename one of the dashboards and retry."
msgstr ""
"このダッシュボードは、そのスラグが現在別のアクティブなダッシュボードで使用されているため、復元できません。"
"いずれかのダッシュボードの名前を変更してから、再度お試しください。"
msgid "Dashboard cannot be unfavorited."
msgstr "ダッシュボードのお気に入りを解除できませんでした。"
@@ -4266,7 +4270,7 @@ msgid "Data refreshed"
msgstr "データを更新しました"
msgid "Data type"
msgstr "データ種類"
msgstr "データ"
msgid "DataFrame include at least one series"
msgstr "データフレームには少なくとも1つの系列が必要です"
@@ -4308,8 +4312,8 @@ msgid ""
"Database driver for importing maybe not installed. Visit the Superset "
"documentation page for installation instructions: "
msgstr ""
"インポートに必要なデータベースドライバーがインストールされていない可能性があります。インストール手順についてはSupersetのドキュメントを確認してください:"
" "
"インポートに必要なデータベースドライバーがインストールされていない可能性があります。"
"インストール手順についてはSupersetのドキュメントを確認してください:"
msgid "Database error"
msgstr "データベースエラー"
@@ -4397,7 +4401,7 @@ msgid "Dataset does not exist"
msgstr "データセットが存在しません"
msgid "Dataset is required"
msgstr "データセットが必要です"
msgstr "データセットは必須です"
msgid "Dataset metric delete failed."
msgstr "データセットメトリックの削除に失敗しました。"
@@ -4439,7 +4443,7 @@ msgid "Datasource does not exist"
msgstr "データソースが存在しません"
msgid "Datasource is required"
msgstr "データソースが必要です"
msgstr "データソースは必須です"
msgid "Datasource is required for validation"
msgstr "検証にはデータソースが必要です"
@@ -4496,7 +4500,7 @@ msgid "Decides which column or measure to sort the base axis by."
msgstr "基準軸の並べ替えに使用する列またはメジャーを選択します。"
msgid "Decimal character"
msgstr "小数点"
msgstr "小数点記号"
msgid "Deck.gl - 3D Grid"
msgstr "Deck.gl - 3Dグリッド"
@@ -4741,7 +4745,7 @@ msgid "Delete item"
msgstr "項目を削除"
msgid "Delete query"
msgstr ""
msgstr "クエリを削除"
msgid "Delete role"
msgstr "ロールを削除"
@@ -4874,7 +4878,7 @@ msgid "Delimiter"
msgstr "区切り文字"
msgid "Delivery method"
msgstr "信方法"
msgstr "信方法"
msgid "Density"
msgstr "密度"
@@ -4898,7 +4902,7 @@ msgid "Deselect all"
msgstr "すべての選択を解除"
msgid "Design with"
msgstr "デザイン"
msgstr "デザイン"
msgid "Details"
msgstr "詳細"
@@ -4934,7 +4938,7 @@ msgid "Difference"
msgstr "差分"
msgid "Dim Gray"
msgstr "ディム・グレー"
msgstr "薄灰色"
msgid "Dimension"
msgstr "ディメンション"
@@ -5018,6 +5022,8 @@ msgid ""
"Display charts on a map. For using this plugin, users first have to "
"create any other chart that can then be placed on the map."
msgstr ""
"地図上にグラフを表示します。"
"このプラグインを使用するには、まず地図上に配置できる任意のグラフを作成する必要があります。"
msgid "Display column in the chart"
msgstr "チャートに列を表示"
@@ -5116,7 +5122,7 @@ msgid ""
msgstr "ディメンションの値に基づいて、各カテゴリをさらにサブカテゴリに分割します。共通部分の除外などに利用できます。"
msgid "Do you want a donut or a pie?"
msgstr "ドーナツ型円型のどちらにしますか?"
msgstr "ドーナツ型円型のどちらにしますか?"
msgid "Documentation"
msgstr "ドキュメント"
@@ -5146,7 +5152,7 @@ msgid "Download is on the way"
msgstr "ダウンロードを準備しています"
msgid "Download to CSV"
msgstr "CSVとしてダウンロード"
msgstr "CSV形式でダウンロード"
msgid "Download to client"
msgstr "クライアントへダウンロード"
@@ -5353,7 +5359,7 @@ msgstr "エラー"
#, python-format
msgid "ERROR: %s"
msgstr ""
msgstr "エラー: %s"
msgid "Edge length"
msgstr "エッジの長さ"
@@ -5445,7 +5451,7 @@ msgid "Edit properties"
msgstr "プロパティを編集"
msgid "Edit query"
msgstr ""
msgstr "クエリを編集"
msgid "Edit report"
msgstr "レポートを編集"
@@ -5529,7 +5535,7 @@ msgid "Email link"
msgstr "メールリンク"
msgid "Email recipients"
msgstr ""
msgstr "メールの受信者"
msgid "Email reports active"
msgstr "メールレポート有効"
@@ -5732,7 +5738,7 @@ msgid "Enter the required %(dbModelName)s credentials"
msgstr "%(dbModelName)s に必要な認証情報を入力してください"
msgid "Enter the unique project id for your database."
msgstr "データベースのユニークなプロジェクトIDを入力してください。"
msgstr "データベースの一意なプロジェクトIDを入力してください。"
msgid "Enter the user's email"
msgstr "ユーザーのメールアドレスを入力"
@@ -5759,7 +5765,7 @@ msgid "Entity"
msgstr "エンティティ"
msgid "Entries per page"
msgstr ""
msgstr "1ページあたりの表示件数"
msgid "Equal Date Sizes"
msgstr "日付サイズを均等にする"
@@ -5882,7 +5888,7 @@ msgid "Error: %s"
msgstr "エラー: %s"
msgid "Error: permalink state not found"
msgstr "エラー: パーマリンクの状態が見つかりません"
msgstr "エラー: 固定リンクの状態が見つかりません"
msgid "Estimate cost"
msgstr "コストを見積もる"
@@ -6015,7 +6021,7 @@ msgid "Export as Example"
msgstr "構成をエクスポート"
msgid "Export as PDF"
msgstr ""
msgstr "PDFとしてエクスポート"
msgid "Export cancelled"
msgstr "エクスポートがキャンセルされました"
@@ -6034,13 +6040,13 @@ msgid "Export failed: %s"
msgstr "エクスポート失敗: %s"
msgid "Export query"
msgstr ""
msgstr "クエリのエクスポート"
msgid "Export screenshot (jpeg)"
msgstr "スクリーンショットをエクスポート (JPEG)"
msgid "Export screenshot (png)"
msgstr ""
msgstr "スクリーンショットをエクスポート (PNG)"
#, python-format
msgid "Export successful: %s"
@@ -6084,11 +6090,15 @@ msgid ""
"Exporting semantic views is not supported yet — %s semantic-view row(s) "
"were skipped."
msgstr ""
"セマンティックビューのエクスポートは現時点ではサポートされていません。"
"%s 個のセマンティックビュー行がスキップされました。"
msgid ""
"Exporting semantic views is not supported yet. Deselect the semantic-view"
" rows and try again."
msgstr ""
"セマンティックビューのエクスポートは、現時点ではサポートされていません。"
"セマンティックビューの行の選択を解除して、もう一度お試しください。"
msgid "Expose database in SQL Lab"
msgstr "SQL Labでデータベースを公開する"
@@ -6192,6 +6202,8 @@ msgid ""
"Failed to export chart data. Please try again or contact your "
"administrator."
msgstr ""
"チャートデータのエクスポートに失敗しました。"
"もう一度お試しいただくか、管理者にお問い合わせください。"
msgid "Failed to fetch API keys"
msgstr "APIキーの取得に失敗しました"
@@ -6267,7 +6279,7 @@ msgstr "項目へのタグ付けに失敗しました"
#, python-format
msgid "Failed to trigger %(alertType)s \"%(alertName)s\": %(error)s"
msgstr ""
msgstr "%(alertType)s 「%(alertName)s」のトリガーに失敗しました%(error)s"
msgid "Failed to update report"
msgstr "レポートの更新に失敗しました"
@@ -6467,7 +6479,7 @@ msgid "Fit columns dynamically"
msgstr "列を動的にフィットさせる"
msgid "Fit data"
msgstr ""
msgstr "フィットデータ"
msgid ""
"Fix the trend line to the full time range specified in case filtered "
@@ -6579,7 +6591,7 @@ msgid "Forecast periods"
msgstr "予測期間"
msgid "Forecast requires at least 2 data points"
msgstr ""
msgstr "予測には、少なくとも2つのデータポイントが必要です"
msgid "Foreign key"
msgstr "外部キー"
@@ -6625,7 +6637,7 @@ msgid "Formatted CSV attached in email"
msgstr "整形済みCSVをメールに添付"
msgid "Formatted Excel attached in email"
msgstr ""
msgstr "メールに書式設定済みのExcelファイルを添付しました"
msgid "Formatted value"
msgstr "整形済みの値"
@@ -7287,7 +7299,7 @@ msgid "Invalid options for %(rolling_type)s: %(options)s"
msgstr "%(rolling_type)s のオプションが無効です: %(options)s"
msgid "Invalid permalink key"
msgstr "パーマリンクキーが無効です"
msgstr "固定リンクキーが無効です"
#, python-format
msgid "Invalid reference to column: \"%(column)s\""
@@ -7498,7 +7510,7 @@ msgid "Label for your query"
msgstr "クエリのラベル"
msgid "Label must not be empty."
msgstr ""
msgstr "ラベルを空にしないでください。"
msgid "Label position"
msgstr "ラベルの位置"
@@ -7780,7 +7792,7 @@ msgid "Lines encoding"
msgstr "線のエンコーディング"
msgid "Link Copied!"
msgstr ""
msgstr "リンクがコピーされました!"
msgid "List"
msgstr "一覧"
@@ -8641,7 +8653,7 @@ msgid "No data after filtering or data is NULL for the latest time record"
msgstr "フィルターの結果、または最新レコードのデータがNULLのため、データがありません"
msgid "No data found"
msgstr ""
msgstr "データが見つかりませんでした"
msgid "No data in file"
msgstr "ファイル内にデータがありません"
@@ -9023,7 +9035,7 @@ msgstr "1つ以上のメトリックが存在しません"
#, python-format
msgid "One or more parameters are missing: %(missing)s"
msgstr ""
msgstr "1つ以上のパラメータが不足しています%(missing)s"
msgid "One or more parameters needed to configure a database are missing."
msgstr "データベース設定に必要な1つ以上のパラメータが不足しています。"
@@ -9627,6 +9639,9 @@ msgid ""
"period (e.g. today so far) against complete prior periods (e.g. all of "
"yesterday)."
msgstr ""
"各時間シフトされた系列を、主系列に合わせて切り詰めるのではなく、その全時間範囲にわたってプロットします。"
"これは、現在の期間の一部(例:今日のこれまでの時間)を、"
"過去の期間全体(例:昨日の全期間)と比較する際に役立ちます。"
msgid "Plot the distance (like flight paths) between origin and destination."
msgstr "出発地と目的地間の距離(飛行経路など)をプロットします。"
@@ -10063,6 +10078,9 @@ msgid ""
"except the subjects defined in the filter, and can be used to define what"
" users can see if no RLS filters within a filter group apply to them."
msgstr ""
"通常のフィルターは、ユーザーがフィルターで参照されている対象と一致する場合、クエリにWHERE句を追加します。"
"ベースフィルターは、フィルターで定義された対象を除くすべてのクエリにフィルターを適用し、"
"フィルターグループ内のRLSフィルターがユーザーに適用されない場合に、そのユーザーに表示できる内容を定義するために使用できます。"
msgid "Relational"
msgstr "関連した"
@@ -10101,7 +10119,7 @@ msgid "Remove customization"
msgstr "カスタマイズを削除"
msgid "Remove dependency"
msgstr ""
msgstr "依存関係を削除"
msgid "Remove filter"
msgstr "フィルターを削除"
@@ -10110,13 +10128,13 @@ msgid "Remove item"
msgstr "項目を削除"
msgid "Remove notification method"
msgstr ""
msgstr "通知方法を削除"
msgid "Remove query from log"
msgstr "ログからクエリを削除"
msgid "Remove sheet"
msgstr ""
msgstr "シートを削除"
#, python-format
msgid "Removed 1 column from the virtual dataset"
@@ -10156,7 +10174,7 @@ msgid "Report Schedule delete failed."
msgstr "レポートスケジュールの削除に失敗しました。"
msgid "Report Schedule execute now failed."
msgstr ""
msgstr "レポートスケジュールの即時実行に失敗しました。"
msgid "Report Schedule execution failed when generating a csv."
msgstr "CSV生成時にレポートスケジュールの実行が失敗しました。"
@@ -10171,7 +10189,7 @@ msgid "Report Schedule execution failed when generating a screenshot."
msgstr "スクリーンショット生成時にレポートスケジュールの実行が失敗しました。"
msgid "Report Schedule execution failed when generating an Excel file."
msgstr ""
msgstr "Excel ファイルの生成中に、レポートスケジュールの実行に失敗しました。"
msgid "Report Schedule execution got an unexpected error."
msgstr "レポートスケジュールの実行中に予期しないエラーが発生しました。"
@@ -10181,10 +10199,12 @@ msgid ""
"Please configure a Celery broker (Redis or RabbitMQ) and worker "
"processes."
msgstr ""
"レポートスケジュールの実行には、Celeryバックエンドの設定が必要です。"
"CeleryブローカーRedisまたはRabbitMQとワーカープロセスを設定してください。"
#, python-format
msgid "Report Schedule executor user %(username)s was not found."
msgstr ""
msgstr "レポートスケジュールの実行者ユーザー %(username)s が見つかりませんでした。"
msgid "Report Schedule is still working, refusing to re-compute."
msgstr "レポートスケジュールはまだ処理中のため、再計算を拒否しました。"
@@ -10833,7 +10853,7 @@ msgid "Search Metrics & Columns"
msgstr "メトリックと列を検索"
msgid "Search a channel by name, or paste a channel ID"
msgstr ""
msgstr "チャンネル名を検索するか、チャンネルIDを貼り付けてください"
msgid "Search all charts"
msgstr "すべてのチャートを検索"
@@ -10875,7 +10895,7 @@ msgid "Search metrics by key or label"
msgstr "メトリックをキーまたはラベルで検索"
msgid "Search records"
msgstr ""
msgstr "記録を検索"
msgid "Search tags"
msgstr "タグを検索"
@@ -10887,7 +10907,7 @@ msgid "Search..."
msgstr "検索…"
msgid "Searches all text fields: Name, Description, Database & Schema"
msgstr ""
msgstr "すべてのテキストフィールド(「名前」、「説明」、「データベース」、「スキーマ」)を検索します"
msgid "Second"
msgstr "秒"
@@ -10928,7 +10948,7 @@ msgid "See all %(tableName)s"
msgstr "すべての %(tableName)s を表示"
msgid "See all dashboards"
msgstr ""
msgstr "すべてのダッシュボードを表示する"
msgid "See less"
msgstr "表示を減らす"
@@ -11190,10 +11210,10 @@ msgid "Select operator"
msgstr "演算子を選択"
msgid "Select or type BCC recipients"
msgstr ""
msgstr "BCCの受信者を選択するか、入力してください"
msgid "Select or type CC recipients"
msgstr ""
msgstr "CCの受信者を選択するか、入力してください"
msgid "Select or type a custom value..."
msgstr "カスタム値を選択または入力..."
@@ -11205,7 +11225,7 @@ msgid "Select or type dataset name"
msgstr "データセット名を選択または入力"
msgid "Select or type email recipients"
msgstr ""
msgstr "メールの受信者を選択するか、入力してください"
msgid "Select page size"
msgstr "ページサイズを選択"
@@ -11392,7 +11412,7 @@ msgid "Send as CSV"
msgstr "CSVとして送信"
msgid "Send as Excel"
msgstr ""
msgstr "Excelとして送信"
msgid "Send as PDF"
msgstr "PDFとして送信"
@@ -11599,7 +11619,7 @@ msgid "Show Metric Names"
msgstr "メトリック名を表示"
msgid "Show Null Values"
msgstr ""
msgstr "NULL値を表示する"
msgid "Show Range Filter"
msgstr "範囲フィルターを表示"
@@ -11637,13 +11657,16 @@ msgid ""
msgstr "スパークラインにY軸を表示します。手動設定されている場合はその最小/最大値を、そうでない場合はデータの最小/最大値を表示します。"
msgid "Show a draggable slider to control the visible range of the Y-axis."
msgstr ""
msgstr "Y軸の表示範囲を制御するための、ドラッグ可能なスライダーを表示します。"
msgid ""
"Show a summary row of total aggregations: the selected metrics in "
"aggregate mode, or the sum of numeric columns in raw records mode. Note "
"that row limit does not apply to the result."
msgstr ""
"集計結果の要約行を表示します。"
"集計モードでは選択されたメトリクス、未加工レコードモードでは数値列の合計が表示されます。"
"なお、結果には行数の制限は適用されません。"
msgid "Show all columns"
msgstr "すべての列を表示"
@@ -11682,7 +11705,7 @@ msgid "Show entries per page"
msgstr "1ページあたりの表示件数"
msgid "Show full range for time shift"
msgstr ""
msgstr "タイムシフトの全範囲を表示する"
msgid ""
"Show hierarchical relationships of data, with the value represented by "
@@ -11763,7 +11786,7 @@ msgstr "値を表示"
msgid ""
"Showcases a metric along with a comparison of value, change, and percent "
"change for a selected time period."
msgstr ""
msgstr "選択した期間における指標と、その値、変化量、および変化率の比較を表示します。"
msgid ""
"Showcases a single metric front-and-center. Big number is best used to "
@@ -11888,7 +11911,7 @@ msgid "Solid"
msgstr "実線"
msgid "Solid background"
msgstr ""
msgstr "単色の背景"
msgid "Some groups could not be resolved and are shown as IDs."
msgstr "一部のグループを解決できなかったため、IDとして表示されています。"
@@ -11900,12 +11923,13 @@ msgid "Some required filters on other tabs have values and will not be cleared"
msgstr "他のタブの一部の必須フィルターには値が設定されており、クリアされません"
msgid "Some tables are not shown. Refine your search."
msgstr ""
msgstr "一部のテーブルは表示されていません。検索条件を絞り込んでください。"
msgid ""
"Something went wrong loading the dashboard. Check the dev console for "
"details."
msgstr ""
"ダッシュボードの読み込み中に問題が発生しました。詳細については開発者コンソールを確認してください。"
msgid "Something went wrong while saving the user info"
msgstr "ユーザー情報の保存中に問題が発生しました"
@@ -12283,7 +12307,7 @@ msgid "Success"
msgstr "成功"
msgid "Success message"
msgstr ""
msgstr "成功メッセージ"
#, python-format
msgid "Successfully changed %s!"
@@ -12351,7 +12375,7 @@ msgid "Swap rows and columns"
msgstr "行と列を入れ替え"
msgid "Sweep angle"
msgstr ""
msgstr "スイープ角度"
msgid ""
"Swiss army knife for visualizing data. Choose between step, line, "
@@ -12506,7 +12530,7 @@ msgid "Tag created"
msgstr "タグを作成しました"
msgid "Tag description"
msgstr ""
msgstr "タグの説明"
msgid "Tag name"
msgstr "タグ名"
@@ -12628,7 +12652,7 @@ msgstr "メールに埋め込まれるテキスト"
#, python-format
msgid "The %(key)s in metadata_cache_timeout must be a non-negative integer."
msgstr ""
msgstr "metadata_cache_timeout 内の %(key)s は、非負の整数でなければなりません。"
#, python-format
msgid "The %s"
@@ -12726,6 +12750,8 @@ msgid ""
"The chart data is too large to download. Please try reducing the date "
"range, limiting rows, or using fewer columns."
msgstr ""
"チャートのデータ量が大きすぎてダウンロードできません。"
"日付範囲を狭めたり、行数を減らしたり、表示する列の数を減らしたりしてみてください。"
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "チャートはまだ読み込み中です。しばらく待ってから再試行してください。"
@@ -12736,7 +12762,7 @@ msgid ""
msgstr "このレポートの対象であるチャートは削除されました。チャートを復元するか、レポートを更新して有効なチャートを指定してください。"
msgid "The chat failed to load."
msgstr ""
msgstr "チャットが読み込めませんでした。"
msgid ""
"The classic. Great for showing how much of a company each investor gets, "
@@ -12819,7 +12845,7 @@ msgid ""
msgstr "このレポートの対象であるダッシュボードは削除されました。ダッシュボードを復元するか、レポートを更新して有効なダッシュボードを指定してください。"
msgid "The dashboard you are looking for may have been deleted or moved."
msgstr ""
msgstr "探しているダッシュボードは、削除されたか、移動された可能性があります。"
msgid "The data source seems to have been deleted"
msgstr "データソースが削除されたようです"
@@ -13035,7 +13061,7 @@ msgstr "メトリックの最大値。設定は任意です。"
msgid ""
"The metadata_cache_timeout must be a mapping from string keys to non-"
"negative integer values."
msgstr ""
msgstr "「metadata_cache_timeout」 は、文字列キーから非負の整数値へのマッピングでなければなりません。"
#, python-format
msgid ""
@@ -13212,7 +13238,7 @@ msgid "The query contains one or more malformed template parameters."
msgstr "クエリに1つ以上の不正なテンプレートパラメータが含まれています。"
msgid "The query context datasource does not match the chart datasource"
msgstr ""
msgstr "クエリのコンテキストのデータソースが、チャートのデータソースと一致しません"
msgid "The query couldn't be loaded"
msgstr "クエリを読み込めませんでした"
@@ -13566,7 +13592,7 @@ msgid "There was an error fetching the filtered charts and dashboards:"
msgstr "フィルタリングされたチャートおよびダッシュボードの取得中にエラーが発生しました:"
msgid "There was an error generating the permalink."
msgstr ""
msgstr "固定リンクの生成中にエラーが発生しました。"
msgid "There was an error loading groups."
msgstr "グループの読み込み中にエラーが発生しました。"
@@ -13662,7 +13688,7 @@ msgstr "選択したレイヤーの削除中に問題が発生しました: %s"
#, python-format
msgid "There was an issue deleting the selected queries: %s"
msgstr ""
msgstr "選択したクエリの削除中に問題が発生しました:%s"
#, python-format
msgid "There was an issue deleting the selected templates: %s"
@@ -13699,7 +13725,7 @@ msgid "There was an issue exporting the selected dashboards"
msgstr "選択したダッシュボードのエクスポート中に問題が発生しました"
msgid "There was an issue exporting the selected queries"
msgstr ""
msgstr "選択したクエリのエクスポート中に問題が発生しました"
msgid "There was an issue exporting the selected themes"
msgstr "選択したテーマのエクスポート中に問題が発生しました"
@@ -13840,7 +13866,7 @@ msgstr ""
"注釈データを含むチャートに引き継ぐかどうかを制御します。"
msgid "This dashboard does not exist"
msgstr ""
msgstr "このダッシュボードは存在しません"
msgid "This dashboard is managed externally, and can't be edited in Superset"
msgstr "このダッシュボードは外部で管理されているため、Superset では編集できません"
@@ -14133,7 +14159,7 @@ msgid "Time Grain"
msgstr "時間粒度"
msgid "Time Grain must be specified when using Time Comparison."
msgstr ""
msgstr "「時間比較」を使用する場合は、「時間粒度」を指定する必要があります。"
msgid "Time Granularity"
msgstr "時間粒度"
@@ -14402,6 +14428,9 @@ msgid ""
" angle is a multiple of 90°, the chart is automatically re-centered to "
"make use of the empty space."
msgstr ""
"チャートがカバーする総角度度単位。360°では完全な円が描かれ、180°では半円が描かれます。"
"スイープ角度が180°以下で、開始角度が90°の倍数である場合、空きスペースを活用するために、"
"チャートの中心位置が自動的に再設定されます。"
msgid "Total color"
msgstr "合計の色"
@@ -14426,7 +14455,7 @@ msgid "Transparent"
msgstr "透明"
msgid "Transparent background"
msgstr ""
msgstr "透明な背景"
msgid "Transpose pivot"
msgstr "ピボットを転置"
@@ -14456,7 +14485,7 @@ msgid "Trigger Alert If..."
msgstr "アラートの発生条件..."
msgid "Trigger now"
msgstr ""
msgstr "今すぐ実行"
msgid "True"
msgstr "真 (True)"
@@ -14561,7 +14590,7 @@ msgid "URL parameters"
msgstr "URLパラメータ"
msgid "UUID to track the execution status"
msgstr ""
msgstr "実行状況を追跡するためのUUID"
msgid "Unable to calculate such a date delta"
msgstr "指定された期間差を計算できません"
@@ -14620,7 +14649,7 @@ msgstr "ダウンロード用ペイロードを生成できません"
#, python-format
msgid "Unable to generate forecast: %(error)s"
msgstr ""
msgstr "予測を生成できませんでした:%(error)s"
msgid ""
"Unable to identify temporal column for date range time comparison.Please "
@@ -14631,7 +14660,7 @@ msgstr "期間比較のための時間列を特定できません。データセ
msgid ""
"Unable to interpret the time offset: %(offset)s. Use a relative time such"
" as \"1 month ago\"."
msgstr ""
msgstr "時間オフセット %(offset)s を解釈できません。「1ヶ月前」などの相対的な時間を使用してください。"
msgid ""
"Unable to load columns for the selected table. Please select a different "
@@ -15501,7 +15530,7 @@ msgid "Whether to display bubbles on top of countries"
msgstr "国の上にバブルを表示するかどうか"
msgid "Whether to display entries with null values in the hierarchy"
msgstr ""
msgstr "階層内でNULL値を持つエントリを表示するかどうか"
msgid "Whether to display in the chart"
msgstr "チャートに表示するかどうか"
@@ -15626,6 +15655,8 @@ msgid ""
"Whether to sort tooltip by the selected metric in descending order. On "
"stacked charts, values are shown in ascending order."
msgstr ""
"ツールチップを選択した指標に基づいて降順で並べ替えるかどうか。"
"積み上げグラフでは、値は昇順で表示されます。"
msgid "Whether to truncate metrics"
msgstr "メトリックを切り詰めるかどうか"
@@ -15785,7 +15816,7 @@ msgid "Y-axis bounds"
msgstr "Y軸の境界"
msgid "Y-axis range slider"
msgstr ""
msgstr "Y軸の範囲スライダー"
msgid "Y-scale interval"
msgstr "Y軸の間隔"
@@ -16021,26 +16052,36 @@ msgid ""
"You must be a chart editor in order to delete. Please reach out to a "
"chart editor to request modifications or edit access."
msgstr ""
"削除を行うには、チャート編集者である必要があります。"
"変更依頼や編集権限の付与については、チャート編集者にお問い合わせください。"
msgid ""
"You must be a chart editor in order to edit. Please reach out to a chart "
"editor to request modifications or edit access."
msgstr ""
"編集を行うには、チャート編集者である必要があります。"
"変更依頼や編集権限の付与については、チャート編集者にお問い合わせください。"
msgid ""
"You must be a dashboard editor in order to delete. Please reach out to a "
"dashboard editor to request modifications or edit access."
msgstr ""
"削除を行うには、ダッシュボード編集者である必要があります。"
"変更や編集権限の付与をご希望の場合は、ダッシュボード編集者にお問い合わせください。"
msgid ""
"You must be a dashboard editor in order to edit. Please reach out to a "
"dashboard editor to request modifications or edit access."
msgstr ""
"編集を行うには、ダッシュボード編集者である必要があります。"
"変更依頼や編集権限の付与については、ダッシュボード編集者にお問い合わせください。"
msgid ""
"You must be a dataset editor in order to delete. Please reach out to a "
"dataset editor to request modifications or edit access."
msgstr ""
"削除を行うには、データセット編集者である必要があります。"
"変更の依頼や編集権限の付与については、データセット編集者にお問い合わせください。"
msgid ""
"You must be a dataset editor in order to edit. Please reach out to a "
@@ -16104,6 +16145,9 @@ msgid ""
"into multiple dashboards) or raise the "
"SUPERSET_DASHBOARD_POSITION_DATA_LIMIT config setting."
msgstr ""
"ダッシュボードが大きすぎて保存できません。シリアル化されたレイアウトの長さは %s ですが、制限値は %s です。"
"ダッシュボードのサイズを縮小するか(たとえば複数のダッシュボードに分割するなど)"
"SUPERSET_DASHBOARD_POSITION_DATA_LIMIT 設定値を上げてください。"
msgid "Your query could not be saved"
msgstr "クエリを保存できませんでした"
@@ -16877,7 +16921,7 @@ msgid ""
msgstr "パーセンタイルは、1つ目が2つ目より小さい2つの数値のリストまたはタプルである必要があります"
msgid "permalink state not found"
msgstr "パーマリンクの状態が見つかりません"
msgstr "固定リンクの状態が見つかりません"
#. do-not-translate
msgid "pivoted_xlsx"
@@ -16906,7 +16950,7 @@ msgid "quarter"
msgstr "四半期"
msgid "queries"
msgstr ""
msgstr "クエリ"
msgid "query"
msgstr "クエリ"
@@ -16952,7 +16996,7 @@ msgid "seconds"
msgstr "秒"
msgid "semantic layer"
msgstr ""
msgstr "セマンティックレイヤー"
msgid "series"
msgstr "系列"

View File

@@ -2017,13 +2017,26 @@ def _process_datetime_column(
# Parse with or without format (suppress warning if no format)
if format_to_use:
df[col.col_label] = pd.to_datetime(
converted = pd.to_datetime(
df[col.col_label],
utc=False,
format=format_to_use,
errors="coerce",
exact=False,
)
# A format that coerces every non-null value to NaT is a mismatch
# (e.g. an epoch-millis column that inherited a '%Y' string format
# when used as a chart's granularity). Assigning it would silently
# blank the whole column, so keep the original values instead.
if df[col.col_label].notna().any() and not converted.notna().any():
logger.warning(
"Datetime format %s coerced every value of column %s to NaT; "
"keeping the original values",
format_to_use,
col.col_label,
)
else:
df[col.col_label] = converted
else:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message=".*Could not infer format.*")

View File

@@ -35,6 +35,7 @@ from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.models import User
from flask_babel import _
from sqlalchemy.exc import NoResultFound
from werkzeug.exceptions import BadRequest
from superset import appbuilder, dataframe, db, result_set, viz
from superset.common.db_query_status import QueryStatus
@@ -197,6 +198,27 @@ def loads_request_json(request_json_data: str) -> dict[Any, Any]:
return parsed if isinstance(parsed, dict) else {}
def get_request_json_body() -> dict[Any, Any]:
"""Parse the request body as JSON, coercing failures to ``{}``.
``request.is_json`` only inspects the Content-Type header, not whether the
body is actually parseable JSON. Callers reaching ``get_form_data`` from a
non-HTTP-chart-data context (e.g. an MCP tool call rendering
``filter_values()``) can have a request context whose Content-Type claims
JSON but whose body isn't a JSON chart-data payload, which makes Werkzeug
raise ``BadRequest`` from ``request.get_json()``. A well-formed but
non-object JSON body (e.g. ``null``, a scalar, or an array) is coerced to
``{}`` too, since callers treat the result as a mapping.
"""
if not request.is_json:
return {}
try:
data = request.get_json(cache=True)
except BadRequest:
return {}
return data if isinstance(data, dict) else {}
#: Parameter names `url_for` interprets itself rather than appending to the
#: query string. Request-supplied query keys matching these must never be
#: forwarded as `url_for` kwargs.
@@ -345,7 +367,7 @@ def get_form_data(
form_data: dict[str, Any] = initial_form_data or {}
if has_request_context():
json_data = request.get_json(cache=True) if request.is_json else {}
json_data = get_request_json_body()
# chart data API requests are JSON
first_query = (

View File

@@ -17,6 +17,10 @@
from typing import Any, Optional, Type
from unittest import mock
from superset.async_events.async_query_manager import (
AsyncQueryJobException,
AsyncQueryTokenException,
)
from superset.async_events.cache_backend import (
RedisCacheBackend,
RedisSentinelCacheBackend,
@@ -30,12 +34,16 @@ from tests.integration_tests.test_app import app
class TestAsyncEventApi(SupersetTestCase):
UUID = "943c920-32a5-412a-977d-b8e47d36f5a4"
JOB_ID = "10a0bd9a-03c8-4737-9345-f4234ba86512"
def fetch_events(self, last_id: Optional[str] = None):
base_uri = "api/v1/async_event/"
uri = f"{base_uri}?last_id={last_id}" if last_id else base_uri
return self.client.get(uri)
def cancel_event(self, job_id: str):
return self.client.post(f"api/v1/async_event/{job_id}/cancel")
def run_test_with_cache_backend(self, cache_backend_cls: Type[Any], test_func):
app._got_first_request = False
async_query_manager_factory.init_app(app)
@@ -136,3 +144,67 @@ class TestAsyncEventApi(SupersetTestCase):
self.client.set_cookie(app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME"], "")
rv = self.fetch_events()
assert rv.status_code == 401
def _test_cancel_logic(self, mock_cache):
with mock.patch.object(async_query_manager, "cancel_job") as mock_cancel:
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 200
mock_cancel.assert_called_once()
assert mock_cancel.call_args.args[0] == self.JOB_ID
response = json.loads(rv.data.decode("utf-8"))
assert response["result"] == {"job_id": self.JOB_ID, "status": "cancelled"}
def _test_cancel_invalid_job_id_logic(self, mock_cache):
# job_id lands in a Redis key, so reject anything that isn't a UUID
# before it reaches the cache backend.
with mock.patch.object(async_query_manager, "cancel_job") as mock_cancel:
rv = self.cancel_event("not-a-uuid")
assert rv.status_code == 400
mock_cancel.assert_not_called()
def _test_cancel_forbidden_logic(self, mock_cache):
with mock.patch.object(
async_query_manager,
"cancel_job",
side_effect=AsyncQueryTokenException("nope"),
):
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 403
def _test_cancel_not_found_logic(self, mock_cache):
with mock.patch.object(
async_query_manager,
"cancel_job",
side_effect=AsyncQueryJobException("gone"),
):
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 404
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_redis_cache_backend(self, mock_uuid4):
self.run_test_with_cache_backend(RedisCacheBackend, self._test_cancel_logic)
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_invalid_job_id(self, mock_uuid4):
self.run_test_with_cache_backend(
RedisCacheBackend, self._test_cancel_invalid_job_id_logic
)
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_forbidden(self, mock_uuid4):
self.run_test_with_cache_backend(
RedisCacheBackend, self._test_cancel_forbidden_logic
)
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_not_found(self, mock_uuid4):
self.run_test_with_cache_backend(
RedisCacheBackend, self._test_cancel_not_found_logic
)
def test_cancel_no_login(self):
app._got_first_request = False
async_query_manager_factory.init_app(app)
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 401

View File

@@ -24,6 +24,7 @@ from pytest import fixture, mark, raises # noqa: PT013
from superset import security_manager
from superset.async_events.async_query_manager import (
AsyncQueryJobException,
AsyncQueryManager,
AsyncQueryTokenException,
)
@@ -31,6 +32,7 @@ from superset.async_events.cache_backend import (
RedisCacheBackend,
RedisSentinelCacheBackend,
)
from superset.utils import json
JWT_TOKEN_SECRET = "some_secret" # noqa: S105
JWT_TOKEN_COOKIE_NAME = "superset_async_jwt" # noqa: S105
@@ -306,6 +308,7 @@ def test_submit_chart_data_job_as_guest_user(
},
{},
],
task_id=ANY,
expires=3600,
)
@@ -414,7 +417,174 @@ def test_submit_explore_json_job_as_guest_user(
"json",
False,
],
task_id=ANY,
expires=3600,
)
assert "guest_token" not in job_meta
@fixture
def cancellable_manager():
"""A manager wired to a mock Redis backend for cancellation tests."""
manager = AsyncQueryManager()
manager._jwt_expiration_seconds = 3600
manager._stream_prefix = "async-events-"
manager._cache = mock.Mock(spec=RedisCacheBackend)
return manager
def test_init_job_registers_cancellable_record(cancellable_manager):
"""init_job persists the owner identity a later cancel must match."""
cancellable_manager.init_job("chan-1", 7)
cancellable_manager._cache.set.assert_called_once()
key, value = cancellable_manager._cache.set.call_args.args
assert key.startswith("async-events-job-cancel:")
assert json.loads(value) == {"channel_id": "chan-1", "user_id": 7}
def test_cancel_job_authorized_revokes_task(cancellable_manager):
cancellable_manager._stream_limit = 100
cancellable_manager._stream_limit_firehose = 1000
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
cancellable_manager._cache.set.return_value = True
with mock.patch("superset.extensions.celery_app") as celery_app:
cancellable_manager.cancel_job("job-1", "chan-1", 7)
celery_app.control.revoke.assert_called_once_with(
"job-1", terminate=True, signal="SIGUSR1"
)
# The job is flagged cancelled (conditionally, xx=True) so the worker knows
# what the signal it is about to receive means.
assert cancellable_manager._cache.set.call_args.kwargs["xx"] is True
flagged = json.loads(cancellable_manager._cache.set.call_args.args[1])
assert flagged["cancelled"] is True
def test_cancel_job_emits_the_terminal_event(cancellable_manager):
"""A task revoked before a worker picks it up never reports on itself."""
cancellable_manager._stream_limit = 100
cancellable_manager._stream_limit_firehose = 1000
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
cancellable_manager._cache.set.return_value = True
with mock.patch("superset.extensions.celery_app"):
cancellable_manager.cancel_job("job-1", "chan-1", 7)
scoped_stream, event_data = cancellable_manager._cache.xadd.call_args_list[0].args[
:2
]
assert scoped_stream == "async-events-chan-1"
assert json.loads(event_data["data"]) == {
"channel_id": "chan-1",
"job_id": "job-1",
"user_id": 7,
"status": AsyncQueryManager.STATUS_CANCELLED,
"errors": [],
"result_url": None,
}
# the record has to outlive the event: the worker still needs to recognize
# the signal on its way as a cancellation
cancellable_manager._cache.delete.assert_not_called()
def test_cancel_job_completed_between_read_and_flag(cancellable_manager):
"""If the job's record is cleared after the auth read, don't revoke."""
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
# Conditional (xx) write finds no key: the job finished and cleaned up.
cancellable_manager._cache.set.return_value = None
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryJobException),
):
cancellable_manager.cancel_job("job-1", "chan-1", 7)
celery_app.control.revoke.assert_not_called()
def test_cancel_job_wrong_user_is_rejected(cancellable_manager):
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryTokenException),
):
cancellable_manager.cancel_job("job-1", "chan-1", 999)
celery_app.control.revoke.assert_not_called()
def test_cancel_job_wrong_channel_is_rejected(cancellable_manager):
"""A matching user on a different channel still cannot cancel the job."""
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryTokenException),
):
cancellable_manager.cancel_job("job-1", "other-chan", 7)
celery_app.control.revoke.assert_not_called()
def test_cancel_job_unknown_raises(cancellable_manager):
cancellable_manager._cache.get.return_value = None
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryJobException),
):
cancellable_manager.cancel_job("job-1", "chan-1", 7)
celery_app.control.revoke.assert_not_called()
def test_is_job_cancelled(cancellable_manager):
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7, "cancelled": True}
)
assert cancellable_manager.is_job_cancelled("job-1") is True
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
assert cancellable_manager.is_job_cancelled("job-1") is False
cancellable_manager._cache.get.return_value = None
assert cancellable_manager.is_job_cancelled("job-1") is False
def test_is_job_cancelled_swallows_cache_errors(cancellable_manager):
"""A cache failure must not escape and mask the worker's original error."""
cancellable_manager._cache.get.side_effect = RuntimeError("redis down")
assert cancellable_manager.is_job_cancelled("job-1") is False
def test_update_job_clears_registry_before_terminal_event(cancellable_manager):
"""Clearing first is what makes a cancel that lost the race a 404."""
calls = []
cancellable_manager._stream_limit = 100
cancellable_manager._stream_limit_firehose = 1000
cancellable_manager._cache.delete.side_effect = lambda *_: calls.append("delete")
cancellable_manager._cache.xadd.side_effect = lambda *_: calls.append("xadd")
job_metadata = {"channel_id": "chan-1", "job_id": "job-1", "user_id": 7}
cancellable_manager.update_job(job_metadata, AsyncQueryManager.STATUS_DONE)
cancellable_manager._cache.delete.assert_called_once_with(
"async-events-job-cancel:job-1"
)
assert calls == ["delete", "xadd", "xadd"]

View File

@@ -532,6 +532,42 @@ class TestQueryContextFactory:
assert query_object.columns == ["ds", "other_col"]
def test_apply_granularity_no_filter_to_remove(self):
"""No x-axis and no temporal filters leaves the filters untouched."""
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [{"col": "other_col", "op": "==", "val": "value"}]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [{"col": "other_col", "op": "==", "val": "value"}]
def test_apply_granularity_with_adhoc_temporal_filter(self):
"""An adhoc temporal filter is matched on its SQL expression."""
adhoc_column = {"label": "ds_expr", "sqlExpression": "DATE(ds)"}
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"},
{"col": "DATE(ds)", "op": "TEMPORAL_RANGE", "val": "a : b"},
]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"}
]
def test_apply_filters_with_time_range(self):
"""Test _apply_filters with time_range"""
query_object = Mock(spec=QueryObject)

View File

@@ -24,6 +24,7 @@ from superset.connectors.sqla.models import Database, SqlaTable
from superset.daos.dashboard import DashboardDAO
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils import json
from tests.unit_tests.conftest import with_feature_flags
@@ -117,3 +118,53 @@ def test_set_dash_metadata_preserves_soft_deleted_members(
)
# And the position slot kept its UUID rather than being nulled.
assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
def test_set_dash_metadata_preserves_refresh_frequency(session: Session) -> None:
"""set_dash_metadata must not reset refresh_frequency when absent from data.
Regression test for #42116: ``data.get("refresh_frequency", 0)`` would
unconditionally overwrite the existing value with 0 whenever the caller
did not include ``refresh_frequency`` in the data dict.
"""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that does NOT include refresh_frequency
# (e.g. changing only the title via the PropertiesModal).
DashboardDAO.set_dash_metadata(dashboard, {"color_scheme": "superset"})
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 30, (
"refresh_frequency should be preserved when not present in data"
)
def test_set_dash_metadata_updates_refresh_frequency_when_present(
session: Session,
) -> None:
"""set_dash_metadata must update refresh_frequency when it IS in data."""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash_2",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that explicitly sets refresh_frequency to 0.
DashboardDAO.set_dash_metadata(
dashboard, {"refresh_frequency": 0, "color_scheme": "superset"}
)
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 0, (
"refresh_frequency should be updated when present in data"
)

View File

@@ -0,0 +1,46 @@
# 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.
from pathlib import Path
import yaml
def test_image_tag_services_use_bundled_packages() -> None:
repository_root = Path(__file__).resolve().parents[2]
compose = yaml.safe_load(
(repository_root / "docker-compose-image-tag.yml").read_text(),
)
image = compose["x-superset-image"]
image_services = [
service
for service in compose["services"].values()
if service.get("image") == image
]
assert image_services
assert all(
service.get("environment", {}).get("DEV_MODE") == "false"
for service in image_services
)
assert all(
all(
not isinstance(volume, str) or ":/app/superset-core" not in volume
for volume in service.get("volumes", [])
)
for service in image_services
)

View File

@@ -216,6 +216,102 @@ class TestLoggingMiddlewareOnCallTool:
assert call_kwargs["slice_id"] == 20
assert call_kwargs["curated_payload"]["dataset_id"] == 30
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_extracts_chart_id_from_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""generate_chart takes no chart_id as input, so on a successful
create the new chart's ID must be pulled from the response body
instead -- otherwise every retry logs slice_id=None and a
successful attempt can't be told apart from the failed ones.
"""
middleware = LoggingMiddleware()
ctx = _make_context(name="generate_chart", params={"dataset_id": 5})
response_text = (
'{"success": true, "chart": {"id": 123, "slice_name": "My Chart"}}'
)
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["slice_id"] == 123
assert call_kwargs["curated_payload"]["slice_id"] == 123
assert call_kwargs["curated_payload"]["success"] is True
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_extracts_dashboard_id_from_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""generate_dashboard likewise creates an ID that only appears in
the response, not the input params."""
middleware = LoggingMiddleware()
ctx = _make_context(name="generate_dashboard", params={"chart_ids": [1, 2]})
response_text = '{"success": true, "dashboard": {"id": 456}}'
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["dashboard_id"] == 456
assert call_kwargs["curated_payload"]["dashboard_id"] == 456
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_does_not_extract_id_on_failed_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""A failed create (error schema response, no exception raised)
must not report a chart_id -- nothing was actually persisted."""
middleware = LoggingMiddleware()
ctx = _make_context(name="generate_chart", params={"dataset_id": 5})
response_text = (
'{"success": false, "chart": null, '
'"error": {"error_type": "validation_error"}}'
)
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["curated_payload"]["success"] is False
assert call_kwargs["slice_id"] is None
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_prefers_input_slice_id_over_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""When chart_id is already known from input params (e.g.
update_chart), the response body must not override it."""
middleware = LoggingMiddleware()
ctx = _make_context(name="update_chart", params={"chart_id": 111})
response_text = '{"success": true, "chart": {"id": 999}}'
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["slice_id"] == 111
class TestLoggingMiddlewareOnMessage:
"""Tests for LoggingMiddleware.on_message()."""
@@ -468,6 +564,40 @@ class TestIsErrorResponse:
assert call_kwargs["curated_payload"]["tool"] == "get_chart_info"
class TestExtractOutputIds:
"""Tests for LoggingMiddleware._extract_output_ids()."""
def test_returns_none_for_non_json_body(self) -> None:
"""A malformed/non-JSON response body must not raise -- the
defensive try/except should fall back to (None, None)."""
middleware = LoggingMiddleware()
result = ToolResult(
content=[mt.TextContent(type="text", text="not valid json {{{")]
)
assert middleware._extract_output_ids(result) == (None, None)
def test_returns_none_for_empty_content(self) -> None:
"""A ToolResult with no content items must not raise."""
middleware = LoggingMiddleware()
assert middleware._extract_output_ids(ToolResult(content=[])) == (
None,
None,
)
def test_returns_none_for_non_dict_json_body(self) -> None:
"""A JSON body that parses but isn't an object (e.g. a bare
list) must not raise and must yield no IDs."""
middleware = LoggingMiddleware()
result = ToolResult(content=[mt.TextContent(type="text", text="[1, 2, 3]")])
assert middleware._extract_output_ids(result) == (None, None)
def test_extracts_both_ids_from_flat_response(self) -> None:
middleware = LoggingMiddleware()
response_text = '{"chart_id": 123, "dashboard_id": 456}'
result = ToolResult(content=[mt.TextContent(type="text", text=response_text)])
assert middleware._extract_output_ids(result) == (456, 123)
class TestMiddlewareChainOrder:
"""Test that the middleware order from server.py logs failures correctly.

View File

@@ -571,3 +571,53 @@ def test_stringify_values_non_serializable_dict_falls_back_to_str() -> None:
# Must not raise — falls back to str()
result = stringify_values(data)
assert result[0] == str({"key": _Unserializable()})
def test_empty_result_set_preserves_column_metadata() -> None:
"""
Test that column metadata is preserved when query returns zero rows.
When a query returns no data but has a valid cursor description, the
column names and types from cursor_description should be preserved
in the result set. This allows downstream consumers (like the UI)
to display column headers even for empty result sets.
"""
data: DbapiResult = []
description = [
("id", "int", None, None, None, None, True),
("name", "varchar", None, None, None, None, True),
("created_at", "timestamp", None, None, None, None, True),
]
result_set = SupersetResultSet(
data,
description, # type: ignore
BaseEngineSpec,
)
# Verify column count
assert len(result_set.columns) == 3
# Verify column names are preserved
column_names = [col["column_name"] for col in result_set.columns]
assert column_names == ["id", "name", "created_at"]
assert result_set.columns[0]["type"] == BaseEngineSpec.get_datatype(
description[0][1]
)
assert result_set.columns[1]["type"] == BaseEngineSpec.get_datatype(
description[1][1]
)
assert result_set.columns[2]["type"] == BaseEngineSpec.get_datatype(
description[2][1]
)
# Verify the PyArrow table has the correct schema
assert result_set.table.num_rows == 0
assert len(result_set.table.column_names) == 3
assert list(result_set.table.column_names) == ["id", "name", "created_at"]
# Verify DataFrame conversion works
df = result_set.to_pandas_df()
assert len(df) == 0
assert list(map(str, df.columns)) == ["id", "name", "created_at"]

View File

@@ -94,3 +94,10 @@ def test_fetch_gives_up_after_max_retries() -> None:
change_detector.fetch_files_github_api("http://api")
assert urlopen_mock.call_count == change_detector.MAX_RETRIES
def test_image_tag_compose_changes_trigger_python_tests() -> None:
assert change_detector.detect_changes(
["docker-compose-image-tag.yml"],
change_detector.PATTERNS["python"],
)

View File

@@ -18,6 +18,7 @@ from typing import Any
from unittest import mock
import pytest
from celery.exceptions import SoftTimeLimitExceeded
from flask_babel import lazy_gettext as _
from superset.commands.chart.exceptions import ChartDataQueryFailedError
@@ -63,6 +64,62 @@ def test_load_chart_data_into_cache_with_error(
)
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
def test_load_chart_data_into_cache_cancelled_emits_no_event(
mock_query_context_schema_cls, mock_async_query_manager, mock_security_manager
):
"""A revoke leaves the terminal event to the cancel request that sent it."""
from superset.tasks.async_queries import load_chart_data_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict[str, Any] = {}
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
# Sync Mock: is_job_cancelled is a plain method, but patching the manager
# yields an AsyncMock whose calls would otherwise return truthy coroutines.
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=True)
mock_query_context_schema_cls.return_value.load.side_effect = (
SoftTimeLimitExceeded()
)
with pytest.raises(SoftTimeLimitExceeded):
load_chart_data_into_cache(job_metadata, form_data)
mock_async_query_manager.is_job_cancelled.assert_called_once_with("job-1")
mock_async_query_manager.update_job.assert_not_called()
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
def test_load_chart_data_into_cache_timeout_emits_error(
mock_query_context_schema_cls, mock_async_query_manager, mock_security_manager
):
"""A genuine timeout reports an error, or the client waits forever."""
from superset.tasks.async_queries import load_chart_data_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict[str, Any] = {}
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
mock_async_query_manager.STATUS_ERROR = "error"
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=False)
mock_query_context_schema_cls.return_value.load.side_effect = (
SoftTimeLimitExceeded()
)
with pytest.raises(SoftTimeLimitExceeded):
load_chart_data_into_cache(job_metadata, form_data)
mock_async_query_manager.update_job.assert_called_once_with(
job_metadata,
"error",
errors=[{"message": "A timeout occurred while loading chart data"}],
)
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
@@ -373,3 +430,71 @@ def test_load_explore_json_into_cache_falls_back_to_string_for_generic_exception
errors = mock_async_query_manager.update_job.call_args[1]["errors"]
assert errors == ["boom"]
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.get_viz")
@mock.patch("superset.tasks.async_queries.get_datasource_info")
def test_load_explore_json_into_cache_cancelled_emits_no_event(
mock_get_datasource_info,
mock_get_viz,
mock_async_query_manager,
mock_security_manager,
):
"""A revoke leaves the terminal event to the cancel request that sent it."""
from superset.tasks.async_queries import load_explore_json_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict = {}
mock_get_datasource_info.return_value = (1, "table")
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
# Sync Mock: is_job_cancelled is a plain method, but patching the manager
# yields an AsyncMock whose calls would otherwise return truthy coroutines.
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=True)
viz_obj = mock.MagicMock()
viz_obj.get_payload.side_effect = SoftTimeLimitExceeded()
mock_get_viz.return_value = viz_obj
with pytest.raises(SoftTimeLimitExceeded):
load_explore_json_into_cache(job_metadata, form_data)
mock_async_query_manager.is_job_cancelled.assert_called_once_with("job-1")
mock_async_query_manager.update_job.assert_not_called()
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.get_viz")
@mock.patch("superset.tasks.async_queries.get_datasource_info")
def test_load_explore_json_into_cache_timeout_emits_error(
mock_get_datasource_info,
mock_get_viz,
mock_async_query_manager,
mock_security_manager,
):
"""A genuine timeout reports an error, or the client waits forever."""
from superset.tasks.async_queries import load_explore_json_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict = {}
mock_get_datasource_info.return_value = (1, "table")
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
mock_async_query_manager.STATUS_ERROR = "error"
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=False)
viz_obj = mock.MagicMock()
viz_obj.get_payload.side_effect = SoftTimeLimitExceeded()
mock_get_viz.return_value = viz_obj
with pytest.raises(SoftTimeLimitExceeded):
load_explore_json_into_cache(job_metadata, form_data)
mock_async_query_manager.update_job.assert_called_once_with(
job_metadata,
"error",
errors=[{"message": "A timeout occurred while loading explore json"}],
)

View File

@@ -235,3 +235,24 @@ def test_cron_schedule_window_chicago_daylight(
assert (
list(cron.strftime("%A, %d %B %Y, %H:%M:%S") for cron in datetimes) == expected # noqa: C400
)
@pytest.mark.parametrize(
"current_dttm, cron, expected",
[
("2020-01-01T08:59:01+00:00", "0 0 30 2 *", []),
("2020-01-01T08:59:01+00:00", "0 0 31 4 *", []),
],
)
def test_cron_schedule_window_invalid_cron_date(
current_dttm: str, cron: str, expected: list[FakeDatetime]
) -> None:
"""
Reports scheduler: Test cron schedule window for a cron that is
syntactically valid but can never match a real calendar date
"""
datetimes = cron_schedule_window(datetime.fromisoformat(current_dttm), cron, "UTC")
assert (
list(cron.strftime("%A, %d %B %Y, %H:%M:%S") for cron in datetimes) == expected # noqa: C400
)

View File

@@ -273,6 +273,22 @@ def test_normalize_dttm_col() -> None:
assert df["__time"].astype(str).tolist() == ["2017-07-01"]
def test_normalize_dttm_col_mismatched_format_keeps_values() -> None:
"""A datetime format that coerces every value to NaT is a mismatch (e.g. an
epoch-millis column that inherited a ``%Y`` string format when used as a
chart's granularity); applying it would silently blank the whole column, so
the original values are kept instead of being nulled. Regression for the
Samples pane showing N/A for such columns."""
df = pd.DataFrame({"year": [1136073600000, 473385600000]}) # epoch ms
before = df["year"].tolist()
normalize_dttm_col(df, (DateColumn(col_label="year", timestamp_format="%Y"),))
# not blanked to NaT/None
assert df["year"].notna().all()
assert df["year"].tolist() == before
def test_normalize_dttm_col_epoch_seconds() -> None:
"""Test conversion of epoch seconds."""
df = pd.DataFrame(

View File

@@ -0,0 +1,53 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for superset.views.utils module"""
from flask import current_app
from superset.views.utils import get_form_data
def test_get_form_data_handles_non_json_body_with_json_content_type() -> None:
"""get_form_data returns gracefully when Content-Type claims JSON but the
body isn't parseable JSON, instead of letting Werkzeug's BadRequest escape.
This is the shape of the request context an MCP tool call runs in when a
chart/dataset SQL template calls the ``filter_values()`` Jinja macro: the
Content-Type header says ``application/json`` but the body is not a JSON
chart-data payload.
"""
with current_app.test_request_context(
data="not-json-at-all", content_type="application/json"
):
form_data, slc = get_form_data()
assert form_data == {}
assert slc is None
def test_get_form_data_handles_non_dict_json_body() -> None:
"""get_form_data coerces a well-formed but non-object JSON body to {}.
``request.get_json()`` happily returns a scalar or list for valid JSON
that isn't a JSON object (e.g. ``null`` or ``42``). Downstream code treats
the parsed body as a mapping, so a non-dict result must not leak through.
"""
with current_app.test_request_context(data="42", content_type="application/json"):
form_data, slc = get_form_data()
assert form_data == {}
assert slc is None