mirror of
https://github.com/apache/superset.git
synced 2026-08-13 03:30:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace0fdeb55 | ||
|
|
a183582291 | ||
|
|
3acef94ef6 | ||
|
|
9638eecdb1 | ||
|
|
7e74fc4192 | ||
|
|
cdca6f7fdc | ||
|
|
b1ca8cac6b | ||
|
|
2cd5efa627 | ||
|
|
a273fe4d62 | ||
|
|
d203f0de33 | ||
|
|
a75f9b67b2 | ||
|
|
3f0858e35d | ||
|
|
68c145adc3 | ||
|
|
4a9aecda4a | ||
|
|
46b2d7d7a9 | ||
|
|
f8600471fa | ||
|
|
b23c65e04f | ||
|
|
aa8255c55c | ||
|
|
10b7bfc8c1 | ||
|
|
89cab1860e | ||
|
|
b7585122c8 | ||
|
|
f2d80a183e | ||
|
|
69adecd6a3 | ||
|
|
fbffae0444 | ||
|
|
6ce7c2e8de | ||
|
|
105820f1f4 | ||
|
|
92b1b0a219 | ||
|
|
c39a47cbac | ||
|
|
dacda71f77 | ||
|
|
12a21c8933 | ||
|
|
13fa3810a8 | ||
|
|
3356f4d3e1 | ||
|
|
4a17c49d74 | ||
|
|
ea1ce7140c | ||
|
|
038414ea5c | ||
|
|
5bb54cc96b | ||
|
|
fb276b08dd | ||
|
|
6e8b3bf976 | ||
|
|
55024e8f4d | ||
|
|
b98bd2a07a | ||
|
|
0a3a35018c | ||
|
|
e6179036ec | ||
|
|
81b4d580db | ||
|
|
9acfac1523 | ||
|
|
aa9af6c307 | ||
|
|
fbb3056508 | ||
|
|
ffbce27c9b | ||
|
|
fe8b218a5f | ||
|
|
f5fe9bfa26 | ||
|
|
7f1c47521e | ||
|
|
0fffa74bc6 | ||
|
|
738ebf9cc6 | ||
|
|
98dff2e170 | ||
|
|
b5ad4a7a07 | ||
|
|
1230b9091b | ||
|
|
852d0182b5 |
+1
-1
@@ -38,7 +38,7 @@
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke
|
||||
/superset/translations/ @sfirke @rusackas
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@ updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 10
|
||||
# Bump the lower bound to the new version, not just widen the upper
|
||||
# bound. Without this, a `sqlglot>=28.10.0, <29` constraint upgraded
|
||||
# to `<30` would keep the stale lower bound forever, dragging
|
||||
# transitively-resolved versions with it. See #40186 (review thread).
|
||||
versioning-strategy: increase
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check.outputs.superset-extensions-cli
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
@@ -36,9 +36,9 @@ Screenshots will be taken but no messages actually sent as long as `ALERT_REPORT
|
||||
#### In your `Dockerfile`
|
||||
|
||||
You'll need to extend the Superset image to include a headless browser. Your options include:
|
||||
- Use Playwright with Chrome: this is the recommended approach as of version 4.1.x or greater. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Read the code comments there as you'll also need to change a feature flag in your config.
|
||||
- Use Firefox: you'll need to install geckodriver and Firefox.
|
||||
- Use Chrome without Playwright: you'll need to install Chrome and set the value of `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
- Use Playwright with Chromium: this is the recommended approach as of version 4.1.x or greater. Playwright always uses Chromium — the `WEBDRIVER_TYPE` config setting has no effect when Playwright is active. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Enable the `PLAYWRIGHT_REPORTS_AND_THUMBNAILS` feature flag in your config to activate it.
|
||||
- Use Firefox (Selenium): you'll need to install geckodriver and Firefox. Set `WEBDRIVER_TYPE` to `"firefox"` in your `superset_config.py`.
|
||||
- Use Chrome (Selenium): you'll need to install Chrome. Set `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
|
||||
In Superset versions <=4.0x, users installed Firefox or Chrome and that was documented here.
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
title: Dashboard Performance
|
||||
hide_title: true
|
||||
sidebar_position: 5
|
||||
version: 1
|
||||
---
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Dashboard Performance
|
||||
|
||||
A dashboard's perceived speed is determined by three independent things: how
|
||||
many charts have to render, how many queries the backend can execute
|
||||
concurrently, and how quickly the underlying data warehouse can return
|
||||
results. Superset gives you levers for the first two; the third belongs to
|
||||
your warehouse. This page covers the dashboard-side levers and the practical
|
||||
guidance around them.
|
||||
|
||||
## Is there a maximum chart count per dashboard?
|
||||
|
||||
**No hard limit is enforced** — Superset has no configuration key that
|
||||
caps the number of charts on a dashboard. In practice, dashboards behave
|
||||
well up to a few dozen charts. Beyond that, you'll typically feel friction
|
||||
on the initial load and during cross-filter / time-range updates, even with
|
||||
the lazy-loading optimizations described below.
|
||||
|
||||
Rough thresholds to keep in mind:
|
||||
|
||||
- **Under ~25 charts**: usually no perceptible problem.
|
||||
- **25–50 charts**: still fine, but you start to want tabs to break the
|
||||
page into chunks the user actually looks at.
|
||||
- **Over ~50 charts**: split into multiple dashboards or use tabs
|
||||
aggressively. The bottleneck is rarely Superset itself — it's the
|
||||
warehouse executing dozens of queries in parallel and the browser
|
||||
rendering dozens of chart frames.
|
||||
|
||||
These are guidelines, not guarantees. A dashboard of 100 sparkline-style
|
||||
charts hitting a fast cache behaves very differently from a dashboard of
|
||||
20 heavy aggregations against a cold warehouse.
|
||||
|
||||
## Lazy rendering — `DASHBOARD_VIRTUALIZATION`
|
||||
|
||||
Superset's dashboard layout is virtualized at the row level. Charts that
|
||||
are far below the user's current scroll position are not rendered (and
|
||||
therefore don't fetch data) until the user scrolls them into view, and they
|
||||
are unmounted again if scrolled well past. This is on by default.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION` (default: `True`)
|
||||
|
||||
The flag is `stable` and marked for path-to-deprecation — meaning the
|
||||
behavior will eventually be non-optional, but the flag still exists so
|
||||
operators can disable it if a specific layout misbehaves.
|
||||
|
||||
**Behavior** (from `superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx`):
|
||||
|
||||
- A chart is rendered when its row scrolls within **1 viewport height** of
|
||||
the visible area.
|
||||
- A chart is unmounted when its row scrolls more than **4 viewport
|
||||
heights** away from the visible area.
|
||||
- Tabs that aren't currently selected don't render their content at all
|
||||
(see below).
|
||||
- The unmounting half is skipped in **embedded** mode (so an embedded
|
||||
dashboard keeps its charts mounted once they've been seen, which avoids
|
||||
re-fetching on scroll-up). Both halves are skipped for **headless /
|
||||
bot** rendering (so screenshot / report jobs load every chart).
|
||||
|
||||
## Deferred data fetch — `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
|
||||
|
||||
By default, `DASHBOARD_VIRTUALIZATION` controls *rendering* — but charts
|
||||
that don't render also don't fetch data, because Superset's chart
|
||||
components issue their data request on mount. `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
|
||||
is a supplementary flag that further defers the data request itself, useful
|
||||
for backends where opening a connection or compiling a query is expensive
|
||||
even if the result is later thrown away.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION_DEFER_DATA` (default: `False`)
|
||||
|
||||
Enable this if you see warehouse load spike on dashboard *open* even
|
||||
though most charts are off-screen.
|
||||
|
||||
## Per-tab lazy loading
|
||||
|
||||
**This is on by default and has no flag.** A tab's content is not rendered
|
||||
until the user activates that tab, so charts inside an unselected tab do
|
||||
not fetch data on dashboard open. When the user clicks the tab, that
|
||||
tab's charts mount and fetch in the normal way.
|
||||
|
||||
Practically: tabs are the single most effective tool for a large
|
||||
dashboard. Splitting 60 charts across 4 tabs effectively turns dashboard
|
||||
open into "load ~15 charts," and the remaining ones lazy-load only if the
|
||||
user goes looking.
|
||||
|
||||
## Is there a switch to cap concurrent chart queries?
|
||||
|
||||
**No.** Superset does not implement a frontend-side concurrent-request
|
||||
limiter. Each chart issues its own data request when it mounts, and the
|
||||
browser handles parallelism (typically ~6 in-flight HTTP requests per
|
||||
origin, then the rest queue). Backend throughput is bounded by your
|
||||
Gunicorn worker count for synchronous query execution, or by your Celery
|
||||
worker pool when [async queries](./async-queries-celery.mdx) are enabled.
|
||||
|
||||
If you need to throttle warehouse load, the right place is:
|
||||
|
||||
1. The warehouse itself (connection pool / concurrency limits).
|
||||
2. Superset's Celery configuration (smaller worker pool when async
|
||||
queries are on).
|
||||
3. Splitting heavy charts across tabs or separate dashboards (each
|
||||
dashboard load only fetches what's visible).
|
||||
|
||||
## Splitting strategies
|
||||
|
||||
When a dashboard outgrows comfortable performance, the options in order
|
||||
of effort:
|
||||
|
||||
**1. Move sections into tabs.** Same dashboard, but only the active tab's
|
||||
charts fetch. This is the cheapest change and often the only one needed.
|
||||
|
||||
**2. Cache aggressively.** A Redis cache backend (see
|
||||
[Caching](./cache.mdx)) means repeat dashboard loads serve from cache
|
||||
rather than re-hitting the warehouse. This is especially impactful for
|
||||
dashboards opened by many users in close succession.
|
||||
|
||||
**3. Enable async queries.** [Async query execution](./async-queries-celery.mdx)
|
||||
via Celery decouples query duration from request lifetime, so a slow
|
||||
chart doesn't block the page. The user sees other charts come in as
|
||||
their queries complete.
|
||||
|
||||
**4. Split into multiple dashboards.** Group related charts into purpose-
|
||||
specific dashboards rather than one mega-dashboard. Link them from a
|
||||
landing dashboard or a navigation menu.
|
||||
|
||||
**5. Pre-aggregate at the warehouse level.** If the same expensive
|
||||
aggregation appears across many charts, materialize it as a view or
|
||||
scheduled table in the warehouse so each chart query is a cheap lookup.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The feature flags above are set in `superset_config.py`, e.g.:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"DASHBOARD_VIRTUALIZATION": True,
|
||||
"DASHBOARD_VIRTUALIZATION_DEFER_DATA": True,
|
||||
}
|
||||
```
|
||||
|
||||
- See [Feature Flags](./feature-flags.mdx) for the full list of supported
|
||||
flags and their lifecycle stages.
|
||||
- Report and screenshot jobs (alerts, scheduled reports, dashboard
|
||||
exports) intentionally bypass row virtualization so the rendered
|
||||
artifact includes every chart, not just the ones above the fold.
|
||||
+3
-3
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.30",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
@@ -109,8 +109,8 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"webpack": "^5.106.2"
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -206,12 +206,26 @@ async function downloadBadge(url, staticDir) {
|
||||
badgeCache.set(url, webPath);
|
||||
return webPath;
|
||||
} catch (error) {
|
||||
// Fail the build on badge download failure
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges. Please fix the badge URL or remove it.`,
|
||||
// Soft fallback: keep the original remote URL in the rendered output
|
||||
// so the badge still appears for readers, and the docs build continues.
|
||||
// External badge services (notably img.shields.io) rate-limit CI IPs
|
||||
// aggressively, and a transient fetch failure shouldn't take the whole
|
||||
// docs build down with it. Set REMARK_BADGES_STRICT=true to opt back
|
||||
// into hard-fail-the-build behavior (e.g. for release builds where you
|
||||
// want to catch genuinely broken badge URLs).
|
||||
if (process.env.REMARK_BADGES_STRICT === 'true') {
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges (REMARK_BADGES_STRICT=true).`,
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
`[remark-localize-badges] Could not localize ${url} ` +
|
||||
`(${error.message}); falling back to remote URL.`,
|
||||
);
|
||||
badgeCache.set(url, url);
|
||||
return url;
|
||||
} finally {
|
||||
// Clean up the in-flight tracker
|
||||
inFlightDownloads.delete(url);
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"disabled": false,
|
||||
"disabled": true,
|
||||
"lastVersion": "current",
|
||||
"includeCurrentVersion": true,
|
||||
"onlyIncludeVersions": [
|
||||
|
||||
+110
-127
@@ -4455,22 +4455,6 @@
|
||||
dependencies:
|
||||
"@types/ms" "*"
|
||||
|
||||
"@types/eslint-scope@^3.7.7":
|
||||
version "3.7.7"
|
||||
resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz"
|
||||
integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==
|
||||
dependencies:
|
||||
"@types/eslint" "*"
|
||||
"@types/estree" "*"
|
||||
|
||||
"@types/eslint@*":
|
||||
version "9.6.1"
|
||||
resolved "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz"
|
||||
integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
"@types/json-schema" "*"
|
||||
|
||||
"@types/estree-jsx@^1.0.0":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"
|
||||
@@ -4612,7 +4596,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz"
|
||||
integrity sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==
|
||||
|
||||
"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
@@ -4828,100 +4812,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.3", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz#5d6da7e7b236b46452fa00d3904bb6f59615bfde"
|
||||
integrity sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==
|
||||
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
|
||||
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/type-utils" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/type-utils" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.3", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.3.tgz#f46cbc70ae0a25119ef94eac9ecd46714788e1a1"
|
||||
integrity sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==
|
||||
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
|
||||
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz#1be5ae152aad987a156c9a1a9b4256e75cfbbe0c"
|
||||
integrity sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==
|
||||
"@typescript-eslint/project-service@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
|
||||
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.3"
|
||||
"@typescript-eslint/types" "^8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.4"
|
||||
"@typescript-eslint/types" "^8.59.4"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz#91a60f66803fe9dae0696fbab2451f5723f119d2"
|
||||
integrity sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==
|
||||
"@typescript-eslint/scope-manager@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
|
||||
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.3", "@typescript-eslint/tsconfig-utils@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz#88ca9036b42ccdd1e630cfdafd2e042c2ca6a835"
|
||||
integrity sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
|
||||
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz#421fb2448bdfeb301d134a01cd02503f67fd8192"
|
||||
integrity sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==
|
||||
"@typescript-eslint/type-utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
|
||||
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.3", "@typescript-eslint/types@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.3.tgz#b7ca539c5e302fdde9a7cadb73caed107ef8f2cd"
|
||||
integrity sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==
|
||||
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
|
||||
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz#e6bb1408e00b47e431427a40268db4e86cb121ab"
|
||||
integrity sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==
|
||||
"@typescript-eslint/typescript-estree@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
|
||||
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/project-service" "8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.3.tgz#f693f979deb4dc3994de03ff8b23976d625c36c5"
|
||||
integrity sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==
|
||||
"@typescript-eslint/utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
|
||||
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz#820843b1b5ca4290009cf189382abcf6fe00dfa6"
|
||||
integrity sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==
|
||||
"@typescript-eslint/visitor-keys@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
|
||||
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5584,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.30, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.30"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz#58915c74388b05f3b3504026194ea9fa98f6e6b6"
|
||||
integrity sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==
|
||||
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.31"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
|
||||
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7269,13 +7253,13 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.20.0:
|
||||
version "5.20.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d"
|
||||
integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==
|
||||
enhanced-resolve@^5.21.4:
|
||||
version "5.21.5"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
|
||||
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.3.0"
|
||||
tapable "^2.3.3"
|
||||
|
||||
entities@^2.0.0:
|
||||
version "2.2.0"
|
||||
@@ -7391,10 +7375,10 @@ es-iterator-helpers@^1.2.1:
|
||||
iterator.prototype "^1.1.4"
|
||||
safe-array-concat "^1.1.3"
|
||||
|
||||
es-module-lexer@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
|
||||
integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==
|
||||
es-module-lexer@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c"
|
||||
integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==
|
||||
|
||||
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||
version "1.1.1"
|
||||
@@ -9655,10 +9639,10 @@ liquid-json@0.3.1:
|
||||
resolved "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz"
|
||||
integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==
|
||||
|
||||
loader-runner@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz"
|
||||
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
|
||||
loader-runner@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9"
|
||||
integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==
|
||||
|
||||
loader-utils@^1.2.3:
|
||||
version "1.4.2"
|
||||
@@ -14128,15 +14112,15 @@ synckit@^0.11.12:
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"
|
||||
integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
||||
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
||||
|
||||
terser-webpack-plugin@^5.3.17, terser-webpack-plugin@^5.3.9:
|
||||
version "5.3.17"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz#75ea98876297fbb190d2fbb395e982582b859a67"
|
||||
integrity sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==
|
||||
terser-webpack-plugin@^5.3.9, terser-webpack-plugin@^5.5.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3"
|
||||
integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "^0.3.25"
|
||||
jest-worker "^27.4.5"
|
||||
@@ -14407,15 +14391,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.3:
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz#4a41d9007faa539a66292189e2795eeb0b9fca29"
|
||||
integrity sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==
|
||||
typescript-eslint@^8.59.4:
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
|
||||
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.3"
|
||||
"@typescript-eslint/parser" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/eslint-plugin" "8.59.4"
|
||||
"@typescript-eslint/parser" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
@@ -14970,22 +14954,21 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
|
||||
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
|
||||
webpack-sources@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
|
||||
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.106.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.2.tgz#ca8174b4fd80f055cc5a45fcc5577d6db76c8ac5"
|
||||
integrity sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==
|
||||
webpack@^5.107.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.0.tgz#9e0d8d8baf24e76f058103f4f06ac6bb528b645a"
|
||||
integrity sha512-PSxeHk/dmLYZlnTU+vL1Gej6Evg5RNtl3flhxBresfznFnzxinHMzHKloHnywM/3ouQv7/AlZCswWDIkNSggUA==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.7"
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
"@webassemblyjs/ast" "^1.14.1"
|
||||
@@ -14995,20 +14978,20 @@ webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
acorn-import-phases "^1.0.3"
|
||||
browserslist "^4.28.1"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.20.0"
|
||||
es-module-lexer "^2.0.0"
|
||||
enhanced-resolve "^5.21.4"
|
||||
es-module-lexer "^2.1.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
glob-to-regexp "^0.4.1"
|
||||
graceful-fs "^4.2.11"
|
||||
loader-runner "^4.3.1"
|
||||
loader-runner "^4.3.2"
|
||||
mime-db "^1.54.0"
|
||||
neo-async "^2.6.2"
|
||||
schema-utils "^4.3.3"
|
||||
tapable "^2.3.0"
|
||||
terser-webpack-plugin "^5.3.17"
|
||||
terser-webpack-plugin "^5.5.0"
|
||||
watchpack "^2.5.1"
|
||||
webpack-sources "^3.3.4"
|
||||
webpack-sources "^3.4.1"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
|
||||
@@ -92,6 +92,26 @@ class Dimension:
|
||||
grain: Grain | None = None
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
"""
|
||||
Aggregation function applied by a metric.
|
||||
|
||||
Additivity (across an arbitrary set of grouping dimensions):
|
||||
* ``SUM``, ``COUNT``: fully additive — sub-group sums roll up via ``sum``.
|
||||
* ``MIN``, ``MAX``: roll up via ``min`` / ``max`` of sub-group values.
|
||||
* ``AVG``, ``COUNT_DISTINCT``, ``OTHER``: not safely roll-uppable from
|
||||
sub-aggregates without auxiliary data.
|
||||
"""
|
||||
|
||||
SUM = "SUM"
|
||||
COUNT = "COUNT"
|
||||
MIN = "MIN"
|
||||
MAX = "MAX"
|
||||
AVG = "AVG"
|
||||
COUNT_DISTINCT = "COUNT_DISTINCT"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metric:
|
||||
id: str
|
||||
@@ -100,6 +120,7 @@ class Metric:
|
||||
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -17,12 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { PropsWithChildren, ReactNode, SyntheticEvent } from 'react';
|
||||
import {
|
||||
ResizableBox,
|
||||
ResizableBoxProps,
|
||||
ResizeCallbackData,
|
||||
} from 'react-resizable';
|
||||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
import { ResizableBox, ResizeCallbackData } from 'react-resizable';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
|
||||
import 'react-resizable/css/styles.css';
|
||||
@@ -46,14 +42,16 @@ export type Size = ResizeCallbackData['size'];
|
||||
|
||||
export default function ResizablePanel({
|
||||
children,
|
||||
heading = undefined,
|
||||
heading,
|
||||
initialSize = { width: 500, height: 300 },
|
||||
minConstraints = [100, 100] as [number, number],
|
||||
onResize,
|
||||
...props
|
||||
}: PropsWithChildren<Omit<ResizableBoxProps, 'width' | 'height'>> & {
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
heading?: ReactNode;
|
||||
initialSize?: Size;
|
||||
minConstraints?: [number, number];
|
||||
onResize?: (e: SyntheticEvent, data: ResizeCallbackData) => void;
|
||||
}) {
|
||||
const { width, height } = initialSize;
|
||||
return (
|
||||
@@ -61,16 +59,14 @@ export default function ResizablePanel({
|
||||
className="panel"
|
||||
width={width}
|
||||
height={height}
|
||||
axis="both"
|
||||
minConstraints={minConstraints}
|
||||
onResize={
|
||||
onResize
|
||||
? (e: SyntheticEvent, data: ResizeCallbackData) => {
|
||||
const { size } = data;
|
||||
onResize(e, { ...data, size });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
maxConstraints={[Infinity, Infinity]}
|
||||
handleSize={[20, 20]}
|
||||
lockAspectRatio={false}
|
||||
resizeHandles={['se']}
|
||||
transformScale={1}
|
||||
onResize={onResize}
|
||||
>
|
||||
<>
|
||||
{heading ? <div className="panel-heading">{heading}</div> : null}
|
||||
|
||||
Generated
+242
-247
@@ -85,7 +85,7 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^1.1.0",
|
||||
"content-disposition": "^2.0.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -109,8 +109,8 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"markdown-to-jsx": "^9.8.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -208,12 +208,12 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-resizable": "^4.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -222,8 +222,8 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
@@ -269,13 +269,13 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.65.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-resizable": "^4.0.1",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -284,9 +284,9 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-jest": "^29.4.10",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.0",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
@@ -8646,9 +8646,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.65.0.tgz",
|
||||
"integrity": "sha512-jDVaGNURT5pEA9qcabh6WusIoBNybOMMDPCx+EFt+gxo6rVvoUf0+73Xy5x81+ZrxU+ewk5uRBYifjy5pgkcnA==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.66.0.tgz",
|
||||
"integrity": "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8663,9 +8663,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.65.0.tgz",
|
||||
"integrity": "sha512-v0z80IWNA7c9RhUydq9YprBxCVZrQ6Ixls2tdxUC1F/1FFqSfa7xTX+EJf0mj6+BKRg2zWXqWfcbJUnETlLlIw==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8680,9 +8680,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.65.0.tgz",
|
||||
"integrity": "sha512-pL/mG/5gMzBwp1gdc5+Cwi87F9j3XRnPxHGyVj5Zd+dCEV5YkKt0L70PB3EGmEEHxgn4H+jnMS3xLuXs6mZW/Q==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8697,9 +8697,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.65.0.tgz",
|
||||
"integrity": "sha512-jVTneaeuHtqTrKYnhrdH1buhnSorinvpy1sv43ayclfWx/e/DfdRWv+h1fopJcHQbYr5WMcZMmDvnfEBkPZ+1A==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.66.0.tgz",
|
||||
"integrity": "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8714,9 +8714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.65.0.tgz",
|
||||
"integrity": "sha512-8lJQ7B6RloYDUhwVdbSpwT2eKsCN5KP1Scn18ly1tytCuhXhbs0nkfKHT4jWWZBJqmynWuzd+78bF7wILrj6pw==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.66.0.tgz",
|
||||
"integrity": "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8731,9 +8731,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.65.0.tgz",
|
||||
"integrity": "sha512-EgmZY+DeWhLLEnNl70/49j3ltA8I6X9kxMfexupWi2Vwfp6RonGsBaHtGoedLolaU37ne7eDUgoxa3CFB95GZA==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.66.0.tgz",
|
||||
"integrity": "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8748,9 +8748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.65.0.tgz",
|
||||
"integrity": "sha512-OJMWmAYRVBCPPxnYr3j5sXRwHPh1bAuMlTStGco1Z8q3HkvSH4h+A10E9MiRNYmLhUuli5a2P5wmfj8cagiF5Q==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.66.0.tgz",
|
||||
"integrity": "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8765,9 +8765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.65.0.tgz",
|
||||
"integrity": "sha512-D8uNi50LsYKgS0vGARZDRx05TBZeSxAVdLGddSEqQLSU7xsiqdImHPEw55xq8sKA5rCc/4au/5uS7FQALWdLCg==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8782,9 +8782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.65.0.tgz",
|
||||
"integrity": "sha512-IpbA8QGbwFehQhO+YaHwmoI81f93xvywpspf8HrdPCWOIeKwYfM1dhVhO4YKfZewTRRQEPY/JFjTOXTgkwhKrA==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8799,9 +8799,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.65.0.tgz",
|
||||
"integrity": "sha512-ZSe8HgaZdgyHSv2+/pTG68z10+OarB18CkFKQOhRs3lmmP/p2vuigedK2e9d0ztoG2DU/duJzhxXBSjy/492HQ==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -8816,9 +8816,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.65.0.tgz",
|
||||
"integrity": "sha512-DcTERf++v6HyPHukKAr0JFTRqB+YeDEvqzRgNDMaz7jITPf+tlJIwRxodlAqoXMYhNVEZhXdQM5RAAYH8/oPuw==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8833,9 +8833,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.65.0.tgz",
|
||||
"integrity": "sha512-xjhMwuFJwRh40NOBzol4gM5gqAa0xPCJU+GQLM6BydV8TbfkIA7JeyCFNhyfbE9Q/5EWcKYTx62R0cRcjP7DAA==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8850,9 +8850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.65.0.tgz",
|
||||
"integrity": "sha512-lrWSXb8JzboPWYBG6Kunt/eemvjo2oCFXktShsm3yMToY7HjzKLjxh7CljSvGnnZH9oohNFHOKc9xYpGKCPm6w==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -8867,9 +8867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.65.0.tgz",
|
||||
"integrity": "sha512-A7xfghw250m4a1sPV+q44Mow2G5bhiC9FBvhAuIhJS6QovWnqzuL5AFQPEuwOB+PM4DhABkqxVa3Iwe3Y/nFlQ==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8884,9 +8884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.65.0.tgz",
|
||||
"integrity": "sha512-reqOun1+pWO3fW6cv7bsa8hHG0TN3t/82qPdaoJo90FwugXiMjKhZMChmH5Z01cFNRHmxN4+543Fy8478cM/iA==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8901,9 +8901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.65.0.tgz",
|
||||
"integrity": "sha512-KQpqOb/juDBO0xyloDkVDhOVxDUgAfZ2OAAVq99TJScJDzT319xry1QzB9LQohV9QGnA7p6m/XATZkMXc84lwA==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8918,9 +8918,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.65.0.tgz",
|
||||
"integrity": "sha512-xfqcOc3nJFeAd1kDY4T9d3XeJIhr00twaaW0kOAzGPyUHkruXtNJv6zz1Ra9fRtSek5VpW2Yoj5AcwPIlT0ZiQ==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8935,9 +8935,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.65.0.tgz",
|
||||
"integrity": "sha512-JV+pXm45p8sdgs3c7LOPAohW23optCNZETFOXUcjn6cS4PYZhEU/RI54Z5dHdMudab3nw7T48PZILthM+Q0COQ==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -8952,9 +8952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.65.0.tgz",
|
||||
"integrity": "sha512-D7L/oBbskLss21bYrRbFuIs81AiSQV+wRzwck54dOkHIlq2qu1xjLz8u6jCqGH8Fltk8bB5DLBpVhE7v/fA8XQ==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -13639,9 +13639,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
@@ -13796,12 +13796,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-resizable": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-3.0.8.tgz",
|
||||
"integrity": "sha512-Pcvt2eGA7KNXldt1hkhVhAgZ8hK41m0mp89mFgQi7LAAEZiaLgm4fHJ5zbJZ/4m2LVaAyYrrRRv1LHDcrGQanA==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-4.0.0.tgz",
|
||||
"integrity": "sha512-zEMfyoFP/I6L796+dIjXeAVL2q7C4vwCXwQW2be79uPZAQLsI/pU1NOVqqfVQG3FkIxr/n96KjhzdRnWGZcTcg==",
|
||||
"deprecated": "This is a stub types definition. react-resizable provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
"react-resizable": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router": {
|
||||
@@ -14121,17 +14123,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
|
||||
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/type-utils": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/type-utils": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -14144,20 +14146,20 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"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/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||
"@typescript-eslint/types": "^8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14172,14 +14174,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14190,9 +14192,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14207,9 +14209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14221,16 +14223,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14249,16 +14251,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14273,13 +14275,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14353,16 +14355,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14378,14 +14380,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||
"@typescript-eslint/types": "^8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14400,14 +14402,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14418,9 +14420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14435,9 +14437,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14449,16 +14451,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14477,13 +14479,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14618,15 +14620,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -14643,14 +14645,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||
"@typescript-eslint/types": "^8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14665,14 +14667,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14683,9 +14685,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14700,9 +14702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14714,16 +14716,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14742,16 +14744,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14766,13 +14768,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -18958,9 +18960,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.0.tgz",
|
||||
"integrity": "sha512-qqGFOrKmFP1lTfG24opOJFcTMza1BqyTSUKVbMGUP5uRsBH+C00Q1loOk+JSFshyRE0ji4HtCJeNN2WHWd6PGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -25494,12 +25496,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/grid-index": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
|
||||
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/h3-js": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.2.1.tgz",
|
||||
@@ -32882,13 +32878,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.23.1.tgz",
|
||||
"integrity": "sha512-J5M32GunL5KcxvV3ZyLJdr7xtcQ3afAO3VjitLW0v2UVfHjXhq9NO4tkTpn4Dknqwq/pXZG7j1WBfmddLCA26w==",
|
||||
"version": "3.24.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.0.tgz",
|
||||
"integrity": "sha512-R+FdFUB3DnoE5FYASV7lGSiRyMkSblcZ2UEy7b2pt7s5ZbCxFIUPXd0E6iAFd8OdvdA2VtbvZZVylzAZNaurjA==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
"packages/pmtiles-provider",
|
||||
"plugins/mapbox-gl-pmtiles-provider",
|
||||
"test/build/vite",
|
||||
"test/build/webpack",
|
||||
"test/build/typings"
|
||||
@@ -32908,7 +32904,6 @@
|
||||
"earcut": "^3.0.1",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"grid-index": "^1.1.0",
|
||||
"kdbush": "^4.0.2",
|
||||
"martinez-polygon-clipping": "^0.8.1",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
@@ -33043,9 +33038,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-to-jsx": {
|
||||
"version": "9.8.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.0.tgz",
|
||||
"integrity": "sha512-NWL0vDt6BeQgCxc2kK6g2SEPJpQuacu+NJk5j4QT4wlNmbsHdudoAbTGLCIyoPhfMiEqD1OZVBsYK0hvCky/HQ==",
|
||||
"version": "9.8.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.1.tgz",
|
||||
"integrity": "sha512-yq70dLPkBnE2LYFtGTLfRes4qyBDS+a4wDttAA/b/BzVGrbs2e0TfCeSFrMkapCg1lsxYi+42BowuBDxLP9k4Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -37475,9 +37470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.65.0.tgz",
|
||||
"integrity": "sha512-ChUuE3Q7XnAbscvT4XLMsH7HFJmLgLVv9lu+RRgFL5wSXnDqUOzTp5IS8qWDBGd/ZDSzQ2tbX8fjAmijlGLC7A==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.66.0.tgz",
|
||||
"integrity": "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -37490,25 +37485,25 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.65.0",
|
||||
"@oxlint/binding-android-arm64": "1.65.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.65.0",
|
||||
"@oxlint/binding-darwin-x64": "1.65.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.65.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.65.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.65.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.65.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.65.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.65.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.65.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.65.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.65.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.65.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.65.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.65.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.65.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.65.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.65.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.66.0",
|
||||
"@oxlint/binding-android-arm64": "1.66.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.66.0",
|
||||
"@oxlint/binding-darwin-x64": "1.66.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.66.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.66.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.66.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.66.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.66.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.66.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.66.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.66.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.66.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.66.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=0.22.1"
|
||||
@@ -40811,9 +40806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz",
|
||||
"integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-4.0.1.tgz",
|
||||
"integrity": "sha512-FR25Rcfxpi1iKiC7taIrqc1Tz6VnslqM94/IrA1LxoM5C3ap2EqaKLnCit/aKrcn3H4wfzO0nFBadFBc+SzEWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -42585,9 +42580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -45228,9 +45223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.4.9",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz",
|
||||
"integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==",
|
||||
"version": "29.4.10",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
|
||||
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -45240,7 +45235,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"make-error": "^1.3.6",
|
||||
"semver": "^7.7.4",
|
||||
"semver": "^7.8.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
@@ -45441,9 +45436,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
|
||||
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -48915,7 +48910,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49388,7 +49383,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -49408,7 +49403,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
@@ -49988,7 +49983,7 @@
|
||||
"acorn": "^8.16.0",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
@@ -50098,9 +50093,9 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^1.1.0",
|
||||
"content-disposition": "^2.0.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -190,8 +190,8 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"markdown-to-jsx": "^9.8.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -289,12 +289,12 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-resizable": "^4.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -303,8 +303,8 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
@@ -350,13 +350,13 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.65.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-resizable": "^4.0.1",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -365,9 +365,9 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-jest": "^29.4.10",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.0",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -76,7 +76,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -63,7 +63,8 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
return BigInt(value);
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
|
||||
+20
@@ -183,6 +183,26 @@ describe('parseResponse()', () => {
|
||||
expect(responseBigNumber.json.constructor).toEqual('constructor');
|
||||
});
|
||||
|
||||
test('handles big numbers in scientific notation when `parseMethod=json-bigint`', async () => {
|
||||
const mockScientificUrl = '/mock/get/scientific';
|
||||
const mockScientificPayload =
|
||||
'{ "big_double": 4.799703045723905e+32, "negative_big": -4.799703045723905e+32, "small": 1 }';
|
||||
fetchMock.get(mockScientificUrl, mockScientificPayload);
|
||||
|
||||
const responseBigNumber = await parseResponse(
|
||||
callApi({ url: mockScientificUrl, method: 'GET' }),
|
||||
'json-bigint',
|
||||
);
|
||||
|
||||
expect(`${responseBigNumber.json.big_double}`).toEqual(
|
||||
'479970304572390500000000000000000',
|
||||
);
|
||||
expect(`${responseBigNumber.json.negative_big}`).toEqual(
|
||||
'-479970304572390500000000000000000',
|
||||
);
|
||||
expect(responseBigNumber.json.small).toEqual(1);
|
||||
});
|
||||
|
||||
test('rejects if request.ok=false', async () => {
|
||||
expect.assertions(3);
|
||||
const mockNotOkayUrl = '/mock/notokay/url';
|
||||
|
||||
@@ -95,8 +95,11 @@ class FakeMessageChannel {
|
||||
const port2 = new FakeMessagePort();
|
||||
port1.otherPort = port2;
|
||||
port2.otherPort = port1;
|
||||
this.port1 = port1;
|
||||
this.port2 = port2;
|
||||
// FakeMessagePort only implements the subset of MessagePort that
|
||||
// Switchboard exercises; cast at the boundary so the fake satisfies
|
||||
// the consumer signature without weakening the production type.
|
||||
this.port1 = port1 as unknown as MessagePort;
|
||||
this.port2 = port2 as unknown as MessagePort;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function isError(message: Message): message is ErrorMessage {
|
||||
* Calling methods on the switchboard causes messages to be sent through the channel.
|
||||
*/
|
||||
export class Switchboard {
|
||||
port: MessagePort;
|
||||
port!: MessagePort;
|
||||
|
||||
name = '';
|
||||
|
||||
@@ -97,9 +97,9 @@ export class Switchboard {
|
||||
// used to make unique ids
|
||||
incrementor = 1;
|
||||
|
||||
debugMode: boolean;
|
||||
debugMode = false;
|
||||
|
||||
private isInitialised: boolean;
|
||||
private isInitialised = false;
|
||||
|
||||
constructor(params?: Params) {
|
||||
if (!params) {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
VisualMapComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
type DataZoomComponentOption,
|
||||
ToolboxComponent,
|
||||
GraphicComponent,
|
||||
AriaComponent,
|
||||
@@ -280,12 +281,56 @@ function Echart(
|
||||
|
||||
const notMerge = !isDashboardRefreshing;
|
||||
chartRef.current?.dispatchAction({ type: 'hideTip' });
|
||||
// setOption(notMerge:true) replaces the dataZoom config, dropping any
|
||||
// range the user has engaged. Preserve it across the call.
|
||||
const previousZoom = notMerge
|
||||
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
|
||||
?.dataZoom
|
||||
: undefined;
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
replaceMerge: notMerge ? undefined : ['series'],
|
||||
// lazyUpdate defers render, causing tooltip crashes on stale shapes (#39247)
|
||||
lazyUpdate: false,
|
||||
});
|
||||
if (previousZoom?.length) {
|
||||
// Skip restore when the new option reshapes dataZoom (different count
|
||||
// means index-based restore could land on the wrong component).
|
||||
const newZoom = (
|
||||
chartRef.current?.getOption() as {
|
||||
dataZoom?: DataZoomComponentOption[];
|
||||
}
|
||||
)?.dataZoom;
|
||||
if (newZoom?.length === previousZoom.length) {
|
||||
const batch = previousZoom
|
||||
.map((dz, dataZoomIndex) => ({
|
||||
dataZoomIndex,
|
||||
start: dz.start,
|
||||
end: dz.end,
|
||||
startValue: dz.startValue,
|
||||
endValue: dz.endValue,
|
||||
}))
|
||||
.filter(b => {
|
||||
const hasAny =
|
||||
b.start !== undefined ||
|
||||
b.end !== undefined ||
|
||||
b.startValue !== undefined ||
|
||||
b.endValue !== undefined;
|
||||
if (!hasAny) return false;
|
||||
// Default full-range zoom is functionally identical to the
|
||||
// fresh state setOption already produces — skip the dispatch.
|
||||
const isDefaultRange =
|
||||
b.start === 0 &&
|
||||
b.end === 100 &&
|
||||
b.startValue === undefined &&
|
||||
b.endValue === undefined;
|
||||
return !isDefaultRange;
|
||||
});
|
||||
if (batch.length) {
|
||||
chartRef.current?.dispatchAction({ type: 'dataZoom', batch });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
|
||||
const setOption = jest.fn();
|
||||
const on = jest.fn();
|
||||
const off = jest.fn();
|
||||
const resize = jest.fn();
|
||||
const dispose = jest.fn();
|
||||
const dispatchAction = jest.fn();
|
||||
const getOption = jest.fn();
|
||||
|
||||
const mockInstance = {
|
||||
setOption,
|
||||
on,
|
||||
off,
|
||||
resize,
|
||||
dispose,
|
||||
dispatchAction,
|
||||
getOption,
|
||||
getZr: () => ({ on: jest.fn(), off: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
__esModule: true,
|
||||
use: jest.fn(),
|
||||
init: jest.fn(() => mockInstance),
|
||||
registerLocale: jest.fn(),
|
||||
}));
|
||||
jest.mock('echarts/charts', () => ({}));
|
||||
jest.mock('echarts/renderers', () => ({}));
|
||||
jest.mock('echarts/components', () => ({}));
|
||||
jest.mock('echarts/features', () => ({}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import Echart from '../../src/components/Echart';
|
||||
|
||||
const renderEchart = (echartOptions: EChartsCoreOption) => {
|
||||
const refs = { divRef: undefined };
|
||||
return render(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={echartOptions}
|
||||
refs={refs}
|
||||
/>,
|
||||
{ useRedux: true, useTheme: true },
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setOption.mockClear();
|
||||
on.mockClear();
|
||||
off.mockClear();
|
||||
resize.mockClear();
|
||||
dispatchAction.mockClear();
|
||||
getOption.mockReset();
|
||||
});
|
||||
|
||||
test('preserves user dataZoom range across setOption(notMerge)', async () => {
|
||||
// After the user has zoomed, ECharts reports the current dataZoom range
|
||||
// via getOption().dataZoom. We expect Echart to capture this before
|
||||
// setOption replaces the option payload, then restore it via dispatchAction.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ start: 12, end: 48 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
|
||||
// Trigger another setOption call by changing the echartOptions reference
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatchAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'dataZoom',
|
||||
batch: [
|
||||
expect.objectContaining({ dataZoomIndex: 0, start: 12, end: 48 }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not restore when no prior zoom range exists', async () => {
|
||||
// Fresh chart with no engaged zoom: dataZoom config has no start/end.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when prior zoom is at default full range', async () => {
|
||||
// ECharts populates start:0/end:100 on slider dataZoom by default, so
|
||||
// every untouched timeseries would otherwise dispatch a redundant action
|
||||
// on each re-render. Skip the dispatch when the range is just the default.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true, start: 0, end: 100 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when the new option reshapes dataZoom', async () => {
|
||||
// 1st render starts with no engaged zoom; 2nd render captures an engaged
|
||||
// range but the post-setOption dataZoom has a different count, so
|
||||
// index-based restore could write to the wrong component. Skip in that case.
|
||||
getOption
|
||||
// 1st render: previousZoom + newZoom (no engaged values, nothing to dispatch)
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
// 2nd render: previousZoom has user range, but newZoom has 2 entries
|
||||
.mockReturnValueOnce({ dataZoom: [{ start: 12, end: 48 }] })
|
||||
.mockReturnValueOnce({
|
||||
dataZoom: [{ start: 12, end: 48 }, { type: 'inside' }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -27,9 +27,9 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -97,7 +97,7 @@ export function createWrapper(options?: Options) {
|
||||
}
|
||||
|
||||
if (useDnd) {
|
||||
// @ts-expect-error react-dnd types not updated for React 18
|
||||
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
|
||||
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1671,7 +1671,7 @@ export interface VizOptions {
|
||||
|
||||
export function createDatasource(
|
||||
vizOptions: VizOptions,
|
||||
): SqlLabThunkAction<Promise<unknown>> {
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
|
||||
@@ -1691,9 +1691,10 @@ export function createDatasource(
|
||||
}),
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch(createDatasourceSuccess(json as { id: number }));
|
||||
const result = json as { id: number };
|
||||
dispatch(createDatasourceSuccess(result));
|
||||
|
||||
return Promise.resolve(json);
|
||||
return result;
|
||||
})
|
||||
.catch(error => {
|
||||
getClientErrorObject(error).then(e => {
|
||||
@@ -1712,7 +1713,7 @@ export function createDatasource(
|
||||
|
||||
export function createCtasDatasource(
|
||||
vizOptions: Record<string, unknown>,
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
): SqlLabThunkAction<Promise<{ table_id: number }>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
return SupersetClient.post({
|
||||
@@ -1720,9 +1721,14 @@ export function createCtasDatasource(
|
||||
jsonPayload: vizOptions,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch(createDatasourceSuccess(json.result));
|
||||
const result = json.result as { table_id: number };
|
||||
// The endpoint's `result.table_id` IS the dataset id; normalize so
|
||||
// createDatasourceSuccess's `${data.id}__table` resolves correctly.
|
||||
// Without this, the CTAS Explore button silently produced
|
||||
// `"undefined__table"` because `result.id` doesn't exist.
|
||||
dispatch(createDatasourceSuccess({ id: result.table_id }));
|
||||
|
||||
return json.result;
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
const errorMsg = t('An error occurred while creating the data source');
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import { useRef, useEffect, FC, useMemo } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
SqlLabRootState,
|
||||
@@ -86,7 +87,7 @@ const EditorAutoSync: FC = () => {
|
||||
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
|
||||
state => state.sqlLab.editorTabLastUpdatedAt,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const lastSavedTimestampRef = useRef<number>(editorTabLastUpdatedAt);
|
||||
|
||||
const currentQueryEditorId = useSelector<SqlLabRootState, string>(
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { usePrevious } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
@@ -136,7 +137,7 @@ const EditorWrapper = ({
|
||||
height,
|
||||
hotkeys,
|
||||
}: EditorWrapperProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'id',
|
||||
'dbId',
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { useStore } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
@@ -68,7 +69,7 @@ export function useKeywords(
|
||||
catalog,
|
||||
schema,
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const hasFetchedKeywords = useRef(false);
|
||||
// skipFetch is used to prevent re-evaluating memoized keywords
|
||||
// due to updated api results by skip flag
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { JsonObject, VizType } from '@superset-ui/core';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import {
|
||||
createCtasDatasource,
|
||||
addInfoToast,
|
||||
@@ -45,7 +46,7 @@ const ExploreCtasResultsButton = ({
|
||||
const errorMessage = useSelector(
|
||||
(state: SqlLabRootState) => state.sqlLab.errorMessage,
|
||||
);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const buildVizOptions = {
|
||||
table_name: table,
|
||||
@@ -56,7 +57,7 @@ const ExploreCtasResultsButton = ({
|
||||
|
||||
const visualize = () => {
|
||||
dispatch(createCtasDatasource(buildVizOptions))
|
||||
.then((data: { table_id: number }) => {
|
||||
.then(data => {
|
||||
const formData = {
|
||||
datasource: `${data.table_id}__table`,
|
||||
metrics: ['count'],
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import URI from 'urijs';
|
||||
import { pick } from 'lodash';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
@@ -49,7 +50,7 @@ const PopEditorTab: React.FC<{ children?: React.ReactNode }> = ({
|
||||
({ sqlLab: { tabHistory } }) => tabHistory.slice(-1)[0],
|
||||
);
|
||||
const [updatedUrl, setUpdatedUrl] = useState<string>(SQL_LAB_URL);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
useComponentDidUpdate(() => {
|
||||
setQueryEditorId(assigned => assigned ?? activeQueryEditorId);
|
||||
if (activeQueryEditorId) {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { isObject } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
@@ -82,7 +83,7 @@ function QueryAutoRefresh({
|
||||
.map(({ id }) => id),
|
||||
),
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const checkForRefresh = () => {
|
||||
const shouldRequestChecking = shouldCheckForQueries(queries);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Button } from '@superset-ui/core/components';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
@@ -75,7 +75,7 @@ const QueryLimitSelect = ({
|
||||
maxRow,
|
||||
defaultQueryLimit,
|
||||
}: QueryLimitSelectProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const queryEditor = useQueryEditor(queryEditorId, ['id', 'queryLimit']);
|
||||
const queryLimit = queryEditor.queryLimit || defaultQueryLimit;
|
||||
|
||||
@@ -30,7 +30,8 @@ import ProgressBar from '@superset-ui/core/components/ProgressBar';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { QueryResponse, QueryState } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
|
||||
import {
|
||||
queryEditorSetSql,
|
||||
@@ -92,7 +93,7 @@ const QueryTable = ({
|
||||
latestQueryId,
|
||||
}: QueryTableProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedQuery, setSelectedQuery] = useState<QueryResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
@@ -231,7 +232,7 @@ const ResultSet = ({
|
||||
canCopyClipboardSqlLab: canCopyClipboard,
|
||||
} = usePermissions();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
const { showConfirm, ConfirmModal } = useConfirmModal();
|
||||
|
||||
|
||||
+38
-67
@@ -16,8 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import * as reactRedux from 'react-redux';
|
||||
import { act } from 'react';
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
@@ -40,6 +39,19 @@ const mockedProps = {
|
||||
datasource: testQuery,
|
||||
};
|
||||
|
||||
// Render with the SqlLab user fixture preloaded into the mock store so the
|
||||
// component's useSelector(state => state.user) returns a useful value.
|
||||
// Previously this test used jest.spyOn(reactRedux, 'useSelector') to inject
|
||||
// the user directly, which can't intercept calls routed through the typed
|
||||
// useAppSelector hook.
|
||||
const renderModal = (
|
||||
props: Partial<ComponentProps<typeof SaveDatasetModal>> = {},
|
||||
) =>
|
||||
render(<SaveDatasetModal {...mockedProps} {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: { user },
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
result: mockdatasets,
|
||||
dataset_count: 3,
|
||||
@@ -47,17 +59,17 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
// Mock the user
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
beforeEach(() => {
|
||||
useSelectorMock.mockClear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock the createDatasource action
|
||||
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
// the thunk and the production code's .then((data) => clearDatasetCache(data.id))
|
||||
// chain receives `{ id: 123 }`. Individual tests can override per-call as needed.
|
||||
jest.mock('src/SqlLab/actions/sqlLab', () => ({
|
||||
createDatasource: jest.fn(),
|
||||
createDatasource: jest.fn(() => () => Promise.resolve({ id: 123 })),
|
||||
}));
|
||||
jest.mock('src/explore/exploreUtils/formData', () => ({
|
||||
postFormData: jest.fn(),
|
||||
@@ -70,7 +82,7 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SaveDatasetModal', () => {
|
||||
test('renders a "Save as new" field', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const saveRadioBtn = screen.getByRole('radio', {
|
||||
name: /save as new/i,
|
||||
@@ -87,7 +99,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders an "Overwrite existing" field', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
name: /overwrite existing/i,
|
||||
@@ -103,20 +115,20 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a close button', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a save button when "Save as new" is selected', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// "Save as new" is selected when the modal opens by default
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders an overwrite button when "Overwrite existing" is selected', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button to reveal the overwrite confirmation and back buttons
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -130,8 +142,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders the overwrite button as disabled until an existing dataset is selected', async () => {
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -168,8 +179,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a confirm overwrite screen when overwrite is clicked', async () => {
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -215,11 +225,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the schema when creating the dataset', async () => {
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -240,17 +246,9 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the catalog when creating the dataset', async () => {
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(
|
||||
<SaveDatasetModal
|
||||
{...mockedProps}
|
||||
datasource={{ ...mockedProps.datasource, catalog: 'public' }}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
renderModal({
|
||||
datasource: { ...mockedProps.datasource, catalog: 'public' },
|
||||
});
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -271,7 +269,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('does not renders a checkbox button when template processing is disabled', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -280,7 +278,7 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -289,15 +287,11 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -324,15 +318,11 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -393,19 +383,11 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Check the "Include Template Parameters" checkbox
|
||||
@@ -443,19 +425,11 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Do NOT check the "Include Template Parameters" checkbox
|
||||
@@ -489,12 +463,9 @@ describe('SaveDatasetModal', () => {
|
||||
'postFormData',
|
||||
);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
postFormData.mockResolvedValue('chart_key_123');
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
|
||||
@@ -34,7 +34,6 @@ import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
SupersetClient,
|
||||
JsonResponse,
|
||||
JsonObject,
|
||||
QueryResponse,
|
||||
QueryFormData,
|
||||
VizType,
|
||||
@@ -44,16 +43,14 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useAppDispatch, useAppSelector } from 'src/views/store';
|
||||
import rison from 'rison';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
|
||||
import {
|
||||
DatasetRadioState,
|
||||
EXPLORE_CHART_DEFAULT,
|
||||
DatasetOwner,
|
||||
SqlLabRootState,
|
||||
} from 'src/SqlLab/types';
|
||||
import { mountExploreUrl } from 'src/explore/exploreUtils';
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
@@ -221,7 +218,7 @@ export const SaveDatasetModal = ({
|
||||
openWindow = true,
|
||||
formData = {},
|
||||
}: SaveDatasetModalProps) => {
|
||||
const defaultVizType = useSelector<SqlLabRootState, string>(
|
||||
const defaultVizType = useAppSelector(
|
||||
state => state.common?.conf?.DEFAULT_VIZ_TYPE || VizType.Table,
|
||||
);
|
||||
|
||||
@@ -240,8 +237,8 @@ export const SaveDatasetModal = ({
|
||||
>(undefined);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const user = useSelector<SqlLabRootState, User>(state => state.user);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const user = useAppSelector(state => state.user);
|
||||
const dispatch = useAppDispatch();
|
||||
const [includeTemplateParameters, setIncludeTemplateParameters] =
|
||||
useState(false);
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -105,7 +106,7 @@ const SouthPane = ({
|
||||
const { id, tabViewId } = useQueryEditor(queryEditorId, ['tabViewId']);
|
||||
const editorId = tabViewId ?? id;
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
|
||||
const { offline, tables } = useSelector(
|
||||
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
@@ -237,7 +238,7 @@ const SqlEditor: FC<Props> = ({
|
||||
scheduleQueryWarning,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { database, latestQuery, currentQueryEditorId, hasSqlStatement } =
|
||||
useSelector<
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
|
||||
import { resetState } from 'src/SqlLab/actions/sqlLab';
|
||||
import {
|
||||
@@ -69,7 +69,7 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
|
||||
const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } =
|
||||
dbSelectorProps;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const shouldShowReset = window.location.search === '?reset=1';
|
||||
|
||||
// Modal state for Database/Catalog/Schema selector
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
import { useMemo, FC } from 'react';
|
||||
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { MenuDotsDropdown } from '@superset-ui/core/components';
|
||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -90,7 +91,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
||||
);
|
||||
const StatusIcon = queryState ? STATE_ICONS[queryState] : STATE_ICONS.running;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
bindActionCreators(
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ export default function useDatabaseSelector(queryEditorId: string) {
|
||||
SqlLabRootState,
|
||||
SqlLabRootState['sqlLab']['databases']
|
||||
>(({ sqlLab }) => sqlLab.databases);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'dbId',
|
||||
'catalog',
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
|
||||
import {
|
||||
ButtonGroup,
|
||||
@@ -75,7 +76,7 @@ const Fade = styled.div`
|
||||
const TableElement = ({ table, ...props }: TableElementProps) => {
|
||||
const { dbId, catalog, schema, name, expanded, id } = table;
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const {
|
||||
currentData: tableMetadata,
|
||||
isSuccess: isMetadataSuccess,
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
type ChangeEvent,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
@@ -163,7 +164,7 @@ const savePinnedSchemasToStorage = (
|
||||
};
|
||||
|
||||
const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const treeRef = useRef<TreeApi<TreeNodeData>>(null);
|
||||
const tables = useSelector(
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useReducer, useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
Table,
|
||||
@@ -130,7 +130,7 @@ const useTreeData = ({
|
||||
catalog,
|
||||
pinnedTables,
|
||||
}: UseTreeDataParams): UseTreeDataResult => {
|
||||
const reduxDispatch = useDispatch();
|
||||
const reduxDispatch = useAppDispatch();
|
||||
// Schema data from API
|
||||
const {
|
||||
currentData: schemaData,
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ClientErrorObject, getExtensionsRegistry } from '@superset-ui/core';
|
||||
@@ -110,7 +111,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => {
|
||||
};
|
||||
|
||||
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const [databaseName, backend, disableDataPreview] = useSelector<
|
||||
SqlLabRootState,
|
||||
|
||||
+33
-1
@@ -28,7 +28,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
import React from 'react';
|
||||
import DatasourceModalComponent from '.';
|
||||
import DatasourceModalComponent, { buildExtraJsonObject } from '.';
|
||||
|
||||
// Cast to accept partial mock props in tests
|
||||
const DatasourceModal = DatasourceModalComponent as unknown as React.FC<
|
||||
@@ -309,3 +309,35 @@ describe('DatasourceModal', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExtraJsonObject', () => {
|
||||
test('returns "{}" for an item with no warning and no certification', () => {
|
||||
expect(buildExtraJsonObject({} as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('drops warning_markdown when its value is null', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: null } as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('drops warning_markdown when its value is an empty string', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: '' } as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('preserves a non-empty warning_markdown verbatim', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: '⚠ caveat' } as any)).toBe(
|
||||
'{"warning_markdown":"⚠ caveat"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves certification and drops null warning_markdown', () => {
|
||||
expect(
|
||||
buildExtraJsonObject({
|
||||
certified_by: 'data-team',
|
||||
certification_details: 'verified',
|
||||
warning_markdown: null,
|
||||
} as any),
|
||||
).toBe(
|
||||
'{"certification":{"certified_by":"data-team","details":"verified"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ const StyledDatasourceModal = styled(Modal)`
|
||||
}
|
||||
`;
|
||||
|
||||
function buildExtraJsonObject(
|
||||
export function buildExtraJsonObject(
|
||||
item: DatasetObject['metrics'][0] | DatasetObject['columns'][0],
|
||||
) {
|
||||
const certification =
|
||||
@@ -83,7 +83,7 @@ function buildExtraJsonObject(
|
||||
: undefined;
|
||||
return JSON.stringify({
|
||||
certification,
|
||||
warning_markdown: item?.warning_markdown,
|
||||
warning_markdown: item?.warning_markdown || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
selectOption,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
import SelectFilter from './Select';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const mockUpdateFilterValue = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateFilterValue.mockClear();
|
||||
});
|
||||
|
||||
test('select filter with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Regression for sc-104554: the chart-list Owner filter renders options
|
||||
// with ReactNode labels (name + email). The value passed to
|
||||
// updateFilterValue is serialized into URL / filter state and re-used to
|
||||
// render the filter pill on return. It must carry the plain-text name
|
||||
// (from `title`) and not fall back to the numeric user id.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>John Doe</span>
|
||||
<span>john@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: ReactNodeLabel,
|
||||
value: 42,
|
||||
title: 'John Doe',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('John Doe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'John Doe',
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('select filter falls back to stringified value when no string label or title is available', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: <span>123</span>,
|
||||
value: 123,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Something',
|
||||
key: 'something',
|
||||
id: 'something',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('123', 'Something');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: '123',
|
||||
value: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with string label passes label through unchanged', async () => {
|
||||
// Happy-path coverage for the typeof-string branch in onChange, exercised
|
||||
// through the non-async Select wrapper (selects array, no fetchSelects).
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Status',
|
||||
key: 'status',
|
||||
id: 'status',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: 'Published', value: 7 },
|
||||
{ label: 'Draft', value: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Published',
|
||||
value: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
|
||||
// the non-async Select wrapper. Guards against the two wrappers ever
|
||||
// diverging on antd's two-arg onChange shape.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>Jane Roe</span>
|
||||
<span>jane@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Jane Roe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Jane Roe',
|
||||
value: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
|
||||
// The isClear flag is what allows the parent (Filters/index) to suppress
|
||||
// onFilterUpdate side-effects when the user clears the filter rather than
|
||||
// picking a new value. Lock that contract in.
|
||||
const mockOnSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<SelectFilter
|
||||
Header="Owner"
|
||||
initialValue={{ label: 'John Doe', value: 42 }}
|
||||
onSelect={mockOnSelect}
|
||||
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
ref.current?.clearFilter();
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('rehydrates filter pill from initialValue with plain-string label', async () => {
|
||||
// The user-visible regression: after URL/state rehydration the filter pill
|
||||
// must render the human-readable name, not the numeric user id. The fix
|
||||
// ensures the persisted label is a string; this test asserts that string
|
||||
// is what surfaces in the rendered combobox selection.
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owners',
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
value: { label: 'John Doe', value: 42 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -58,14 +58,22 @@ function SelectFilter(
|
||||
) {
|
||||
const [selectedOption, setSelectedOption] = useState(initialValue);
|
||||
|
||||
const onChange = (selected: SelectOption) => {
|
||||
const onChange = (selected: SelectOption, option?: SelectOption) => {
|
||||
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
|
||||
// labeled-value as the first arg and the full option (which carries
|
||||
// `title` and any other fields) as the second. Options may supply a
|
||||
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
|
||||
// filter). Since this object is serialized into the URL and rehydrated
|
||||
// as the filter pill on return, we need a plain string. Prefer `title`
|
||||
// (set by callers to the human-readable name) before falling back to
|
||||
// the value.
|
||||
onSelect(
|
||||
selected
|
||||
? {
|
||||
label:
|
||||
typeof selected.label === 'string'
|
||||
? selected.label
|
||||
: String(selected.value),
|
||||
: (option?.title ?? String(selected.value)),
|
||||
value: selected.value,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface SortColumn {
|
||||
export interface SelectOption {
|
||||
label: ReactNode;
|
||||
value: any;
|
||||
// Plain-text representation of the option. Callers should set this when
|
||||
// `label` is a ReactNode so that the option can be serialized (e.g. into
|
||||
// URL filter state) without losing the human-readable name.
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
|
||||
import { MenuKeys } from 'src/dashboard/types';
|
||||
import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
@@ -122,7 +122,7 @@ export const useDownloadMenuItems = (
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { last } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient, SupersetApiError } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
@@ -105,7 +105,7 @@ export const useDownloadScreenshot = (
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
|
||||
@@ -103,10 +103,12 @@ test('does not allow user to create a report without a name', () => {
|
||||
});
|
||||
|
||||
test('creates a new email report via modal Add button', async () => {
|
||||
// The modal now calls POST /api/v1/report/subscribe; creation_method, owners, and
|
||||
// recipients are derived server-side — the client payload intentionally omits them.
|
||||
fetchMock.post(
|
||||
REPORT_ENDPOINT,
|
||||
'glob:*/api/v1/report/subscribe',
|
||||
{ id: 1, result: {} },
|
||||
{ name: 'post-report' },
|
||||
{ name: 'post-subscribe' },
|
||||
);
|
||||
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true });
|
||||
@@ -114,22 +116,22 @@ test('creates a new email report via modal Add button', async () => {
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
|
||||
// Verify exactly one POST from the modal submit path
|
||||
// Verify exactly one POST to the subscribe endpoint
|
||||
await waitFor(() => {
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
const postCalls = fetchMock.callHistory.calls('post-subscribe');
|
||||
expect(postCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
const postCalls = fetchMock.callHistory.calls('post-subscribe');
|
||||
const body = JSON.parse(postCalls[0].options.body as string);
|
||||
expect(body.name).toBe('Weekly Report');
|
||||
expect(body.type).toBe('Report');
|
||||
expect(body.creation_method).toBe('dashboards');
|
||||
expect(body.crontab).toBeDefined();
|
||||
expect(body.recipients).toBeDefined();
|
||||
expect(body.recipients[0].type).toBe('Email');
|
||||
// creation_method, owners, and recipients are set server-side; not in the client payload
|
||||
expect(body.creation_method).toBeUndefined();
|
||||
expect(body.recipients).toBeUndefined();
|
||||
|
||||
fetchMock.removeRoute('post-report');
|
||||
fetchMock.removeRoute('post-subscribe');
|
||||
});
|
||||
|
||||
test('text-based chart hides screenshot width and shows message content', () => {
|
||||
|
||||
@@ -174,6 +174,28 @@ export const addReport =
|
||||
throw err;
|
||||
});
|
||||
|
||||
export const SUBSCRIBE_REPORT = 'SUBSCRIBE_REPORT' as const;
|
||||
|
||||
export interface SubscribeReportAction {
|
||||
type: typeof SUBSCRIBE_REPORT;
|
||||
json: ReportApiJsonResponse;
|
||||
}
|
||||
|
||||
export const subscribeReport =
|
||||
(report: Partial<ReportObject>) => (dispatch: Dispatch<AnyAction>) =>
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/report/subscribe`,
|
||||
jsonPayload: report,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch({ type: SUBSCRIBE_REPORT, json } as SubscribeReportAction);
|
||||
dispatch(addSuccessToast(t('The report has been created')));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(addDangerToast(t('Failed to create report')));
|
||||
throw err;
|
||||
});
|
||||
|
||||
export const EDIT_REPORT = 'EDIT_REPORT' as const;
|
||||
|
||||
export interface EditReportAction {
|
||||
@@ -255,5 +277,6 @@ export function deleteActiveReport(report: DeletableReport) {
|
||||
export type ReportAction =
|
||||
| SetReportAction
|
||||
| AddReportAction
|
||||
| SubscribeReportAction
|
||||
| EditReportAction
|
||||
| DeleteReportAction;
|
||||
|
||||
@@ -31,8 +31,8 @@ import { Alert } from '@apache-superset/core/components';
|
||||
import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
addReport,
|
||||
editReport,
|
||||
subscribeReport,
|
||||
} from 'src/features/reports/ReportModal/actions';
|
||||
import {
|
||||
Input,
|
||||
@@ -179,26 +179,13 @@ function ReportModal({
|
||||
}, [isEditMode, report]);
|
||||
|
||||
const onSave = async () => {
|
||||
// Create new Report
|
||||
const newReportValues: Partial<ReportObject> = {
|
||||
const commonFields: Partial<ReportObject> = {
|
||||
type: 'Report',
|
||||
active: true,
|
||||
force_screenshot: false,
|
||||
custom_width: currentReport.custom_width,
|
||||
creation_method: creationMethod,
|
||||
dashboard: dashboardId,
|
||||
chart: chart?.id,
|
||||
owners: [userId],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: {
|
||||
target: userEmail,
|
||||
ccTarget: ccEmail,
|
||||
bccTarget: bccEmail,
|
||||
},
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
name: currentReport.name,
|
||||
description: currentReport.description,
|
||||
crontab: currentReport.crontab,
|
||||
@@ -209,12 +196,27 @@ function ReportModal({
|
||||
setCurrentReport({ isSubmitting: true, error: undefined });
|
||||
try {
|
||||
if (isEditMode && currentReport.id) {
|
||||
// Edit path: include all fields, PUT endpoint accepts recipients/owners directly
|
||||
await dispatch(
|
||||
editReport(currentReport.id, newReportValues as ReportObject),
|
||||
editReport(currentReport.id, {
|
||||
...commonFields,
|
||||
creation_method: creationMethod,
|
||||
owners: [userId],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: {
|
||||
target: userEmail,
|
||||
ccTarget: ccEmail,
|
||||
bccTarget: bccEmail,
|
||||
},
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
} as ReportObject),
|
||||
);
|
||||
} else {
|
||||
// Create new report (either not in edit mode, or edit mode without valid ID)
|
||||
await dispatch(addReport(newReportValues as ReportObject));
|
||||
// Subscribe path: creation_method, owners, and recipients are set server-side.
|
||||
await dispatch(subscribeReport(commonFields as ReportObject));
|
||||
}
|
||||
onHide();
|
||||
} catch (e) {
|
||||
|
||||
@@ -21,11 +21,13 @@ import { omit } from 'lodash';
|
||||
import {
|
||||
SET_REPORT,
|
||||
ADD_REPORT,
|
||||
SUBSCRIBE_REPORT,
|
||||
EDIT_REPORT,
|
||||
DELETE_REPORT,
|
||||
ReportAction,
|
||||
SetReportAction,
|
||||
AddReportAction,
|
||||
SubscribeReportAction,
|
||||
EditReportAction,
|
||||
DeleteReportAction,
|
||||
} from './actions';
|
||||
@@ -105,6 +107,25 @@ export default function reportsReducer(
|
||||
};
|
||||
},
|
||||
|
||||
[SUBSCRIBE_REPORT]() {
|
||||
const { result, id } = (action as SubscribeReportAction).json;
|
||||
const report: ReportObject = { ...result, id } as ReportObject;
|
||||
const creationMethod = report.creation_method as ReportCreationMethod;
|
||||
const key = report.dashboard ?? report.chart;
|
||||
|
||||
if (key === undefined) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[creationMethod]: {
|
||||
...state[creationMethod],
|
||||
[key]: report,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
[EDIT_REPORT]() {
|
||||
const actionTyped = action as EditReportAction;
|
||||
const report: ReportObject = {
|
||||
|
||||
@@ -169,6 +169,7 @@ describe('RoleListEditModal', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
// updateRoleUsers is called with the hydrated user IDs
|
||||
const userArg = mockUpdateRoleUsers.mock.calls[0][1];
|
||||
expect(userArg).toEqual([5, 7]);
|
||||
expect(userArg.every((id: unknown) => typeof id === 'number')).toBe(true);
|
||||
@@ -225,6 +226,8 @@ describe('RoleListEditModal', () => {
|
||||
expect(decodedQuery).toEqual({
|
||||
page_size: 100,
|
||||
page: 0,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters: [
|
||||
{
|
||||
col: 'roles',
|
||||
|
||||
@@ -129,8 +129,18 @@ function RoleListEditModal({
|
||||
fetchPaginatedData({
|
||||
endpoint: `/api/v1/security/users/`,
|
||||
pageSize: 100,
|
||||
setData: setRoleUsers,
|
||||
setData: (users: UserObject[]) => {
|
||||
const seen = new Set<number>();
|
||||
setRoleUsers(
|
||||
users.filter(u => {
|
||||
if (seen.has(u.id)) return false;
|
||||
seen.add(u.id);
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
},
|
||||
filters,
|
||||
orderBy: { column: 'id', direction: 'asc' },
|
||||
setLoadingState: (loading: boolean) => setLoadingRoleUsers(loading),
|
||||
loadingKey: 'roleUsers',
|
||||
addDangerToast,
|
||||
@@ -218,7 +228,6 @@ function RoleListEditModal({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
}));
|
||||
|
||||
formRef.current.setFieldsValue({
|
||||
roleUsers: userOptions,
|
||||
});
|
||||
@@ -279,8 +288,8 @@ function RoleListEditModal({
|
||||
|
||||
const handleFormSubmit = async (values: RoleForm) => {
|
||||
try {
|
||||
const userIds = values.roleUsers?.map(user => user.value) || [];
|
||||
const permissionIds = mapSelectedIds(values.rolePermissions);
|
||||
const userIds = mapSelectedIds(values.roleUsers);
|
||||
const groupIds = mapSelectedIds(values.roleGroups);
|
||||
await Promise.all([
|
||||
updateRoleName(id, values.roleName),
|
||||
|
||||
@@ -117,6 +117,7 @@ type LaunchQueue = {
|
||||
|
||||
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
|
||||
const consumerPromises: Promise<void>[] = [];
|
||||
|
||||
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
|
||||
// the component's useEffect — calling it inline deadlocks Jest because the
|
||||
@@ -131,7 +132,11 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
|
||||
if (fileHandle) {
|
||||
const id = setTimeout(() => {
|
||||
pendingTimerIds.delete(id);
|
||||
consumer({ files: [fileHandle] });
|
||||
consumerPromises.push(
|
||||
Promise.resolve(consumer({ files: [fileHandle] })).then(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
}, 0);
|
||||
pendingTimerIds.add(id);
|
||||
}
|
||||
@@ -165,9 +170,19 @@ beforeEach(() => {
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
pendingTimerIds.forEach(id => clearTimeout(id));
|
||||
pendingTimerIds.clear();
|
||||
if (consumerPromises.length > 0) {
|
||||
const results = await Promise.allSettled(consumerPromises);
|
||||
results.forEach(r => {
|
||||
if (r.status === 'rejected') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('LaunchQueue consumer rejected:', r.reason);
|
||||
}
|
||||
});
|
||||
consumerPromises.length = 0;
|
||||
}
|
||||
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import handleResourceExport from './export';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -35,7 +35,10 @@ jest.mock('@apache-superset/core/utils', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('content-disposition');
|
||||
jest.mock('content-disposition', () => ({
|
||||
parse: jest.fn(),
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
// Default no-op mock for pathUtils; specific tests customize ensureAppRoot to simulate app root prefixing
|
||||
jest.mock('./pathUtils', () => ({
|
||||
@@ -156,7 +159,7 @@ test('uses default filename when Content-Disposition is missing', async () => {
|
||||
});
|
||||
|
||||
test('handles Content-Disposition parsing errors gracefully', async () => {
|
||||
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
|
||||
(parseContentDisposition as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('Invalid header');
|
||||
});
|
||||
|
||||
@@ -208,7 +211,7 @@ test('exports multiple resources with correct IDs', async () => {
|
||||
});
|
||||
|
||||
test('parses filename from Content-Disposition with quotes', async () => {
|
||||
(contentDisposition.parse as jest.Mock).mockReturnValueOnce({
|
||||
(parseContentDisposition as jest.Mock).mockReturnValueOnce({
|
||||
type: 'attachment',
|
||||
parameters: { filename: 'my_custom_export.zip' },
|
||||
});
|
||||
@@ -360,7 +363,7 @@ test('handles malformed Content-Disposition header', async () => {
|
||||
} as unknown as Response;
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
|
||||
(parseContentDisposition as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('Parse error');
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import rison from 'rison';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
|
||||
// Maximum blob size for in-memory downloads (100MB)
|
||||
const MAX_BLOB_SIZE = 100 * 1024 * 1024;
|
||||
@@ -75,7 +75,7 @@ export default async function handleResourceExport(
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface FetchPaginatedOptions {
|
||||
setData: (data: any[]) => void;
|
||||
setLoadingState: Dispatch<SetStateAction<any>>;
|
||||
filters?: SupersetFilter[];
|
||||
orderBy?: { column: string; direction: 'asc' | 'desc' };
|
||||
loadingKey: string;
|
||||
addDangerToast: (message: string) => void;
|
||||
errorMessage?: string;
|
||||
@@ -38,6 +39,8 @@ interface QueryObj {
|
||||
page_size: number;
|
||||
page: number;
|
||||
filters?: SupersetFilter[];
|
||||
order_column?: string;
|
||||
order_direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface SupersetFilter {
|
||||
@@ -51,6 +54,7 @@ export const fetchPaginatedData = async ({
|
||||
pageSize = 100,
|
||||
setData,
|
||||
filters,
|
||||
orderBy,
|
||||
setLoadingState,
|
||||
loadingKey,
|
||||
addDangerToast,
|
||||
@@ -66,6 +70,10 @@ export const fetchPaginatedData = async ({
|
||||
if (filters) {
|
||||
queryObj.filters = filters;
|
||||
}
|
||||
if (orderBy) {
|
||||
queryObj.order_column = orderBy.column;
|
||||
queryObj.order_direction = orderBy.direction;
|
||||
}
|
||||
const encodedQuery = rison.encode(queryObj);
|
||||
|
||||
const response = await SupersetClient.get({
|
||||
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
createListenerMiddleware,
|
||||
StoreEnhancer,
|
||||
} from '@reduxjs/toolkit';
|
||||
import type { AnyAction } from 'redux';
|
||||
import {
|
||||
useDispatch,
|
||||
useSelector,
|
||||
type TypedUseSelectorHook,
|
||||
} from 'react-redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import thunk, { type ThunkDispatch } from 'redux-thunk';
|
||||
import { api } from 'src/hooks/apiResources/queryApi';
|
||||
import messageToastReducer from 'src/components/MessageToasts/reducers';
|
||||
import charts from 'src/components/Chart/chartReducer';
|
||||
@@ -188,6 +189,14 @@ export type RootState = ReturnType<typeof store.getState>;
|
||||
// thunks resolve correctly), and `useAppSelector` infers `RootState` without
|
||||
// callers having to annotate every selector. Required ahead of the
|
||||
// react-redux v8+ bump, which tightens dispatch typing — see #39927.
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
//
|
||||
// AppDispatch is declared as ThunkDispatch & store.dispatch rather than
|
||||
// `typeof store.dispatch` because Superset annotates getMiddleware as
|
||||
// ConfigureStoreOptions['middleware'], which erases the middleware tuple type
|
||||
// and leaves store.dispatch typed as Dispatch<AnyAction>. The intersection
|
||||
// restores thunk support without requiring a wider refactor of the middleware
|
||||
// setup.
|
||||
export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction> &
|
||||
typeof store.dispatch;
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
Generated
+146
-146
@@ -23,21 +23,21 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^17.6.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.8.3",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-jest": "^29.4.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
"typescript-eslint": "^8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.0",
|
||||
@@ -1798,9 +1798,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1844,17 +1844,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
|
||||
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/type-utils": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/type-utils": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -1867,7 +1867,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -1883,16 +1883,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1908,14 +1908,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||
"@typescript-eslint/types": "^8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1930,14 +1930,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1948,9 +1948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1965,15 +1965,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1990,9 +1990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2004,16 +2004,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -2071,16 +2071,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2095,13 +2095,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5695,9 +5695,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -6016,9 +6016,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.4.9",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz",
|
||||
"integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==",
|
||||
"version": "29.4.10",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
|
||||
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6028,7 +6028,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"make-error": "^1.3.6",
|
||||
"semver": "^7.7.4",
|
||||
"semver": "^7.8.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
@@ -6199,16 +6199,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
|
||||
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
|
||||
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.59.3",
|
||||
"@typescript-eslint/parser": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3"
|
||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -7894,9 +7894,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
@@ -7938,16 +7938,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
|
||||
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/type-utils": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/type-utils": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -7962,75 +7962,75 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/parser": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||
"@typescript-eslint/types": "^8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@typescript-eslint/type-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -8065,24 +8065,24 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -10730,9 +10730,9 @@
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "2.0.0",
|
||||
@@ -10954,9 +10954,9 @@
|
||||
"requires": {}
|
||||
},
|
||||
"ts-jest": {
|
||||
"version": "29.4.9",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz",
|
||||
"integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==",
|
||||
"version": "29.4.10",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
|
||||
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bs-logger": "^0.2.6",
|
||||
@@ -10965,7 +10965,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"make-error": "^1.3.6",
|
||||
"semver": "^7.7.4",
|
||||
"semver": "^7.8.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"yargs-parser": "^21.1.1"
|
||||
}
|
||||
@@ -11044,15 +11044,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"typescript-eslint": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
|
||||
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
|
||||
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/eslint-plugin": "8.59.3",
|
||||
"@typescript-eslint/parser": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3"
|
||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4"
|
||||
}
|
||||
},
|
||||
"uglify-js": {
|
||||
|
||||
@@ -31,21 +31,21 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^17.6.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.8.3",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-jest": "^29.4.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
"typescript-eslint": "^8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.0",
|
||||
|
||||
@@ -22,7 +22,15 @@ from typing import Any, TYPE_CHECKING
|
||||
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import EXCLUDE, fields, post_load, Schema, validate
|
||||
from marshmallow import (
|
||||
EXCLUDE,
|
||||
fields,
|
||||
post_load,
|
||||
Schema,
|
||||
validate,
|
||||
validates_schema,
|
||||
ValidationError,
|
||||
)
|
||||
from marshmallow.validate import Length, Range
|
||||
from marshmallow_union import Union
|
||||
|
||||
@@ -937,6 +945,42 @@ class ChartDataPostProcessingOperationSchema(Schema):
|
||||
},
|
||||
)
|
||||
|
||||
# Map post-processing operation -> its options schema, for operations that
|
||||
# declare one. Operations without a dedicated schema are not structurally
|
||||
# validated here.
|
||||
_OPTIONS_SCHEMAS: dict[str, type[Schema]] = {
|
||||
"aggregate": ChartDataAggregateOptionsSchema,
|
||||
"rolling": ChartDataRollingOptionsSchema,
|
||||
"select": ChartDataSelectOptionsSchema,
|
||||
"sort": ChartDataSortOptionsSchema,
|
||||
"contribution": ChartDataContributionOptionsSchema,
|
||||
"prophet": ChartDataProphetOptionsSchema,
|
||||
"boxplot": ChartDataBoxplotOptionsSchema,
|
||||
"pivot": ChartDataPivotOptionsSchema,
|
||||
"geohash_decode": ChartDataGeohashDecodeOptionsSchema,
|
||||
"geohash_encode": ChartDataGeohashEncodeOptionsSchema,
|
||||
"geodetic_parse": ChartDataGeodeticParseOptionsSchema,
|
||||
}
|
||||
|
||||
@validates_schema
|
||||
def validate_options(self, data: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Validate ``options`` against the operation's option schema.
|
||||
|
||||
Validation is lenient (unknown keys are ignored) so it surfaces wrong
|
||||
types / out-of-range values on declared fields without rejecting
|
||||
payloads that carry extra keys.
|
||||
"""
|
||||
operation = data.get("operation")
|
||||
options = data.get("options")
|
||||
if not isinstance(operation, str) or not isinstance(options, dict):
|
||||
return
|
||||
schema_cls = self._OPTIONS_SCHEMAS.get(operation)
|
||||
if schema_cls is None:
|
||||
return
|
||||
errors = schema_cls(unknown=EXCLUDE).validate(options)
|
||||
if errors:
|
||||
raise ValidationError({"options": errors})
|
||||
|
||||
|
||||
class ChartDataFilterSchema(Schema):
|
||||
col = fields.Raw(
|
||||
|
||||
@@ -62,6 +62,21 @@ def build_uuid_to_id_map(position: dict[str, Any]) -> dict[str, int]:
|
||||
}
|
||||
|
||||
|
||||
def _remap_charts_in_scope(container: dict[str, Any], id_map: dict[int, int]) -> None:
|
||||
"""Remap source-env chart IDs in ``container["chartsInScope"]`` in place.
|
||||
|
||||
``chartsInScope`` is a denormalized cache of the charts a filter (native
|
||||
or cross-filter) currently applies to. Both surfaces share this contract,
|
||||
so they share this remap. Unresolvable IDs are dropped rather than
|
||||
passed through, matching the convention used for ``scope.excluded``.
|
||||
"""
|
||||
charts_in_scope = container.get("chartsInScope")
|
||||
if isinstance(charts_in_scope, list):
|
||||
container["chartsInScope"] = [
|
||||
id_map[old_id] for old_id in charts_in_scope if old_id in id_map
|
||||
]
|
||||
|
||||
|
||||
def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
|
||||
config: dict[str, Any],
|
||||
chart_ids: dict[str, int],
|
||||
@@ -145,6 +160,8 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
|
||||
id_map[old_id] for old_id in scope_excluded if old_id in id_map
|
||||
]
|
||||
|
||||
_remap_charts_in_scope(native_filter, id_map)
|
||||
|
||||
# fix display control dataset references
|
||||
for customization in (
|
||||
fixed.get("metadata", {}).get("chart_customization_config") or []
|
||||
@@ -170,7 +187,7 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
|
||||
return fixed
|
||||
|
||||
|
||||
def update_cross_filter_scoping(
|
||||
def update_cross_filter_scoping( # noqa: C901
|
||||
config: dict[str, Any], id_map: dict[int, int]
|
||||
) -> dict[str, Any]:
|
||||
# fix cross filter references
|
||||
@@ -185,6 +202,9 @@ def update_cross_filter_scoping(
|
||||
id_map[old_id] for old_id in scope_excluded if old_id in id_map
|
||||
]
|
||||
|
||||
# Global cross-filter chartsInScope mirrors the native-filter case.
|
||||
_remap_charts_in_scope(cross_filter_global_config, id_map)
|
||||
|
||||
if "chart_configuration" in (metadata := fixed.get("metadata", {})):
|
||||
# Build remapped configuration in a single pass for clarity/readability.
|
||||
new_chart_configuration: dict[str, Any] = {}
|
||||
@@ -212,6 +232,11 @@ def update_cross_filter_scoping(
|
||||
if old_id in id_map
|
||||
]
|
||||
|
||||
# Cross-filter chartsInScope mirrors the native-filter case.
|
||||
cross_filters = chart_config.get("crossFilters")
|
||||
if isinstance(cross_filters, dict):
|
||||
_remap_charts_in_scope(cross_filters, id_map)
|
||||
|
||||
new_chart_configuration[str(new_id)] = chart_config
|
||||
|
||||
metadata["chart_configuration"] = new_chart_configuration
|
||||
|
||||
@@ -34,7 +34,10 @@ from superset.commands.report.exceptions import (
|
||||
)
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.reports.models import ReportCreationMethod, ReportScheduleType
|
||||
from superset.reports.models import (
|
||||
ReportCreationMethod,
|
||||
ReportScheduleType,
|
||||
)
|
||||
from superset.reports.types import ReportScheduleExtra
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
@@ -33,12 +33,20 @@ from superset.commands.report.exceptions import (
|
||||
ReportScheduleNameUniquenessValidationError,
|
||||
ReportScheduleNotFoundError,
|
||||
ReportScheduleUpdateFailedError,
|
||||
ReportScheduleUserEmailNotFoundError,
|
||||
)
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.daos.report import ReportScheduleDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.reports.models import ReportSchedule, ReportScheduleType, ReportState
|
||||
from superset.reports.models import (
|
||||
ReportCreationMethod,
|
||||
ReportRecipientType,
|
||||
ReportSchedule,
|
||||
ReportScheduleType,
|
||||
ReportState,
|
||||
)
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user_email
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -89,6 +97,26 @@ class UpdateReportScheduleCommand(UpdateMixin, BaseReportScheduleCommand):
|
||||
):
|
||||
self._properties["last_state"] = ReportState.NOOP
|
||||
|
||||
# For reports created from charts or dashboards the recipient must always
|
||||
# be the requesting user's own email address.
|
||||
if (
|
||||
self._model.creation_method
|
||||
in (
|
||||
ReportCreationMethod.CHARTS,
|
||||
ReportCreationMethod.DASHBOARDS,
|
||||
)
|
||||
and "recipients" in self._properties
|
||||
):
|
||||
if user_email := get_user_email():
|
||||
self._properties["recipients"] = [
|
||||
{
|
||||
"type": ReportRecipientType.EMAIL,
|
||||
"recipient_config_json": {"target": user_email},
|
||||
}
|
||||
]
|
||||
else:
|
||||
exceptions.append(ReportScheduleUserEmailNotFoundError())
|
||||
|
||||
# Validate name/type uniqueness if either is changing
|
||||
if name != self._model.name or report_type != self._model.type:
|
||||
if not ReportScheduleDAO.validate_update_uniqueness(
|
||||
|
||||
+4
-1
@@ -2011,7 +2011,10 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=1).total_seconds())
|
||||
# For workspaces with 10k+ channels, consider increasing to 10
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2
|
||||
|
||||
# The webdriver to use for generating reports. Use one of the following
|
||||
# The webdriver to use for generating reports when using Selenium (not Playwright).
|
||||
# This setting is ignored when PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled, as
|
||||
# Playwright always uses Chromium regardless of this value.
|
||||
# Use one of the following:
|
||||
# firefox
|
||||
# Requires: geckodriver and firefox installations
|
||||
# Limitations: can be buggy at times
|
||||
|
||||
@@ -21,10 +21,11 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
from typing import Any, Callable, Optional, TYPE_CHECKING
|
||||
|
||||
from flask_babel import gettext as __
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
|
||||
from sqlalchemy.dialects.postgresql.base import PGInspector
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
@@ -135,6 +136,34 @@ def parse_options(connect_args: dict[str, Any]) -> dict[str, str]:
|
||||
return {token[0]: token[1] for token in tokens}
|
||||
|
||||
|
||||
def _normalize_interval(v: Any) -> Optional[float]:
|
||||
"""Convert PostgreSQL INTERVAL values to milliseconds.
|
||||
|
||||
psycopg2 and psycopg3 always return INTERVAL values as datetime.timedelta
|
||||
objects. We convert to milliseconds so users can apply the built-in
|
||||
"DURATION" number format for human-readable display (e.g.,
|
||||
"1d 2h 30m 45s") and so the values participate cleanly in numeric
|
||||
aggregations in bar/pie charts.
|
||||
|
||||
Returns None for the NULL case (preserves NULL semantics) and for any
|
||||
unexpected non-timedelta type (avoids producing a mixed-type column
|
||||
when an unfamiliar driver surfaces something other than timedelta).
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
if hasattr(v, "total_seconds"):
|
||||
return v.total_seconds() * 1000
|
||||
# Defensive: psycopg2/3 should always hand us a timedelta. If a future
|
||||
# driver doesn't, surface the surprise in the logs rather than silently
|
||||
# dropping the value so operators can diagnose it.
|
||||
logger.warning(
|
||||
"Cannot normalize PostgreSQL INTERVAL value of type %s to numeric; "
|
||||
"returning None.",
|
||||
type(v).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PostgresBaseEngineSpec(BaseEngineSpec):
|
||||
"""Abstract class for Postgres 'like' databases"""
|
||||
|
||||
@@ -526,8 +555,17 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
|
||||
ENUM(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
(
|
||||
re.compile(r"^interval", re.IGNORECASE),
|
||||
INTERVAL(),
|
||||
GenericDataType.NUMERIC,
|
||||
),
|
||||
)
|
||||
|
||||
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
|
||||
INTERVAL: _normalize_interval,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_schema_from_engine_params(
|
||||
cls,
|
||||
|
||||
@@ -251,7 +251,7 @@ def _generate_ascii_line_chart(data: list[Any], width: int, height: int) -> str:
|
||||
lines.append("═" * min(width, 60))
|
||||
|
||||
# Extract values and labels for plotting
|
||||
values, labels = _extract_time_series_data(data)
|
||||
values, labels, is_temporal = _extract_time_series_data(data)
|
||||
|
||||
if not values:
|
||||
return "No numeric data found for line chart"
|
||||
@@ -265,7 +265,7 @@ def _generate_ascii_line_chart(data: list[Any], width: int, height: int) -> str:
|
||||
lines.extend(sparkline_data)
|
||||
|
||||
# Add trend analysis
|
||||
trend_analysis = _analyze_trend(values)
|
||||
trend_analysis = _analyze_trend(values, is_temporal=is_temporal)
|
||||
lines.append("")
|
||||
lines.append("📊 Trend Analysis:")
|
||||
lines.extend(trend_analysis)
|
||||
@@ -273,10 +273,17 @@ def _generate_ascii_line_chart(data: list[Any], width: int, height: int) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _extract_time_series_data(data: list[Any]) -> tuple[list[float], list[str]]:
|
||||
"""Extract time series data with labels."""
|
||||
def _extract_time_series_data(
|
||||
data: list[Any],
|
||||
) -> tuple[list[float], list[str], bool]:
|
||||
"""Extract time series data with labels.
|
||||
|
||||
Returns a tuple of (values, labels, is_temporal) where is_temporal indicates
|
||||
whether the label column appears to represent temporal data based on its name.
|
||||
"""
|
||||
values = []
|
||||
labels = []
|
||||
is_temporal = False
|
||||
|
||||
for row in data[:20]: # Limit points for readability
|
||||
if isinstance(row, dict):
|
||||
@@ -299,6 +306,7 @@ def _extract_time_series_data(data: list[Any]) -> tuple[list[float], list[str]]:
|
||||
for date_word in ["date", "time", "month", "day", "year"]
|
||||
):
|
||||
label_val = str(val)[:10] # Truncate long dates
|
||||
is_temporal = True
|
||||
else:
|
||||
label_val = str(val)[:8] # Truncate long strings
|
||||
|
||||
@@ -306,7 +314,7 @@ def _extract_time_series_data(data: list[Any]) -> tuple[list[float], list[str]]:
|
||||
values.append(numeric_val)
|
||||
labels.append(label_val or f"P{len(values)}")
|
||||
|
||||
return values, labels
|
||||
return values, labels, is_temporal
|
||||
|
||||
|
||||
def _create_enhanced_line_chart(
|
||||
@@ -407,8 +415,15 @@ def _draw_line_segment(
|
||||
grid[y][x] = "│"
|
||||
|
||||
|
||||
def _analyze_trend(values: list[float]) -> list[str]:
|
||||
"""Analyze trend in the data."""
|
||||
def _analyze_trend(values: list[float], *, is_temporal: bool = True) -> list[str]:
|
||||
"""Analyze trend in the data.
|
||||
|
||||
Args:
|
||||
values: Numeric data points to analyze.
|
||||
is_temporal: Whether the data represents a time series. When False,
|
||||
directional trend analysis is skipped because the ordering of
|
||||
categorical data is arbitrary.
|
||||
"""
|
||||
if len(values) < 2:
|
||||
return ["• Insufficient data for trend analysis"]
|
||||
|
||||
@@ -421,24 +436,28 @@ def _analyze_trend(values: list[float]) -> list[str]:
|
||||
max_val = max(values)
|
||||
avg_val = sum(values) / len(values)
|
||||
|
||||
# Overall trend
|
||||
if last_val > first_val * 1.1:
|
||||
trend_icon = "📈"
|
||||
trend_desc = "Strong upward trend"
|
||||
elif last_val > first_val * 1.05:
|
||||
trend_icon = "📊"
|
||||
trend_desc = "Moderate upward trend"
|
||||
elif last_val < first_val * 0.9:
|
||||
trend_icon = "📉"
|
||||
trend_desc = "Strong downward trend"
|
||||
elif last_val < first_val * 0.95:
|
||||
trend_icon = "📊"
|
||||
trend_desc = "Moderate downward trend"
|
||||
if is_temporal:
|
||||
# Overall trend (only meaningful for temporally ordered data)
|
||||
if last_val > first_val * 1.1:
|
||||
trend_icon = "📈"
|
||||
trend_desc = "Strong upward trend"
|
||||
elif last_val > first_val * 1.05:
|
||||
trend_icon = "📊"
|
||||
trend_desc = "Moderate upward trend"
|
||||
elif last_val < first_val * 0.9:
|
||||
trend_icon = "📉"
|
||||
trend_desc = "Strong downward trend"
|
||||
elif last_val < first_val * 0.95:
|
||||
trend_icon = "📊"
|
||||
trend_desc = "Moderate downward trend"
|
||||
else:
|
||||
trend_icon = "➡️"
|
||||
trend_desc = "Relatively stable"
|
||||
|
||||
analysis.append(f"• {trend_icon} {trend_desc}")
|
||||
else:
|
||||
trend_icon = "➡️"
|
||||
trend_desc = "Relatively stable"
|
||||
analysis.append("• ⚠️ Data is categorical — directional trend not meaningful")
|
||||
|
||||
analysis.append(f"• {trend_icon} {trend_desc}")
|
||||
analysis.append(f"• Range: {min_val:.1f} to {max_val:.1f} (avg: {avg_val:.1f})")
|
||||
|
||||
# Volatility
|
||||
|
||||
@@ -36,7 +36,10 @@ import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Literal
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.errors import SupersetErrorType
|
||||
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
@@ -46,6 +49,31 @@ from superset.mcp_service.common.error_schemas import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Error types from db_engine_spec.extract_errors() that indicate a database
|
||||
# connectivity or authentication issue rather than a query/config problem.
|
||||
#
|
||||
# GENERIC_DB_ENGINE_ERROR is included because many engines (BigQuery,
|
||||
# Snowflake, Athena, Databricks, Trino) lack specific CONNECTION_* regex
|
||||
# patterns in their engine specs — all their connection failures fall back
|
||||
# to this generic type. This is safe here because _compile_chart only runs
|
||||
# after Tier 1 schema validation has already verified columns, metrics, and
|
||||
# filters against the dataset. At that point the SQL is auto-generated by
|
||||
# Superset's query builder, so genuine SQL/config errors are very unlikely.
|
||||
_CONNECTION_ERROR_TYPES = {
|
||||
SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR,
|
||||
SupersetErrorType.CONNECTION_INVALID_PASSWORD_ERROR,
|
||||
SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
|
||||
SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
|
||||
SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
|
||||
SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
|
||||
SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
|
||||
SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
|
||||
SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
|
||||
SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
|
||||
SupersetErrorType.CONNECTION_DATABASE_TIMEOUT,
|
||||
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompileResult:
|
||||
@@ -186,6 +214,19 @@ def _compile_chart(
|
||||
|
||||
return CompileResult(success=True, warnings=warnings, row_count=row_count)
|
||||
except (ChartDataQueryFailedError, ChartDataCacheLoadError) as exc:
|
||||
if _classify_as_database_error(exc, dataset_id):
|
||||
logger.warning(
|
||||
"Database connection error during chart compile check: %s: %s",
|
||||
type(exc).__name__,
|
||||
str(exc),
|
||||
)
|
||||
return CompileResult(
|
||||
success=False,
|
||||
error=f"Database connection error: {exc}",
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
tier="compile",
|
||||
error_obj=_build_database_error(str(exc)),
|
||||
)
|
||||
return CompileResult(
|
||||
success=False,
|
||||
error=str(exc),
|
||||
@@ -201,6 +242,19 @@ def _compile_chart(
|
||||
tier="compile",
|
||||
error_obj=_build_compile_error(str(exc)),
|
||||
)
|
||||
except SQLAlchemyError as exc:
|
||||
logger.warning(
|
||||
"Database connection error during chart compile check: %s: %s",
|
||||
type(exc).__name__,
|
||||
str(exc),
|
||||
)
|
||||
return CompileResult(
|
||||
success=False,
|
||||
error=f"Database connection error: {exc}",
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
tier="compile",
|
||||
error_obj=_build_database_error(str(exc)),
|
||||
)
|
||||
|
||||
|
||||
def _adhoc_filter_column_valid(
|
||||
@@ -278,6 +332,53 @@ def _validate_adhoc_filter_columns(
|
||||
)
|
||||
|
||||
|
||||
def _classify_as_database_error(exc: BaseException, dataset_id: int) -> bool:
|
||||
"""Use the dataset's DB engine spec to classify the error.
|
||||
|
||||
Walks the ``__cause__`` chain for direct ``SQLAlchemyError`` instances,
|
||||
then falls back to the engine spec's ``extract_errors`` regex patterns —
|
||||
the same classification the Superset UI uses.
|
||||
"""
|
||||
# Direct SQLAlchemy errors (unwrapped or in cause chain)
|
||||
current: BaseException | None = exc
|
||||
while current is not None:
|
||||
if isinstance(current, SQLAlchemyError):
|
||||
return True
|
||||
current = current.__cause__
|
||||
|
||||
# Use the dataset's engine spec to classify (same as the UI)
|
||||
try:
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
|
||||
dataset = DatasetDAO.find_by_id(dataset_id)
|
||||
if dataset and dataset.database and isinstance(exc, Exception):
|
||||
errors = dataset.database.db_engine_spec.extract_errors(exc)
|
||||
return any(e.error_type in _CONNECTION_ERROR_TYPES for e in errors)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug(
|
||||
"Failed to classify error via engine spec for dataset %s: %s",
|
||||
dataset_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _build_database_error(message: str) -> ChartGenerationError:
|
||||
"""Wrap a database connection failure in the structured response envelope."""
|
||||
return ChartGenerationError(
|
||||
error_type="database_connection_error",
|
||||
message="Unable to connect to the database.",
|
||||
details=message or "",
|
||||
suggestions=[
|
||||
"Check that the database is online and reachable",
|
||||
"Verify database credentials and connection settings",
|
||||
"Contact your administrator if the issue persists",
|
||||
],
|
||||
error_code="DATABASE_CONNECTION_ERROR",
|
||||
)
|
||||
|
||||
|
||||
def _build_compile_error(message: str) -> ChartGenerationError:
|
||||
"""Wrap a raw compile-failure string in the structured response envelope."""
|
||||
return ChartGenerationError(
|
||||
|
||||
@@ -352,6 +352,20 @@ async def generate_chart( # noqa: C901
|
||||
"Chart creation failed - no chart ID returned"
|
||||
)
|
||||
|
||||
# Reload server-generated timestamps (created_on,
|
||||
# changed_on) so the serializer sees real values.
|
||||
from superset import db
|
||||
|
||||
try:
|
||||
db.session.refresh(chart)
|
||||
except SQLAlchemyError:
|
||||
logger.warning(
|
||||
"Chart %s created but refresh failed; "
|
||||
"continuing with current values",
|
||||
chart.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Chart created successfully: chart_id=%s, chart_name=%s"
|
||||
% (
|
||||
@@ -402,7 +416,7 @@ async def generate_chart( # noqa: C901
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = ChartGenerationError(
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message=(
|
||||
"Chart query failed to execute. The chart was not saved."
|
||||
@@ -532,7 +546,7 @@ async def generate_chart( # noqa: C901
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = ChartGenerationError(
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message=(
|
||||
"Chart query failed to execute. "
|
||||
|
||||
@@ -335,6 +335,15 @@ def generate_dashboard( # noqa: C901
|
||||
|
||||
db.session.add(dashboard)
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
try:
|
||||
db.session.refresh(dashboard)
|
||||
except SQLAlchemyError:
|
||||
logger.warning(
|
||||
"Dashboard %s created but refresh failed; "
|
||||
"continuing with current values",
|
||||
dashboard.id,
|
||||
exc_info=True,
|
||||
)
|
||||
except SQLAlchemyError as db_err:
|
||||
try:
|
||||
db.session.rollback() # pylint: disable=consider-using-transaction
|
||||
@@ -405,6 +414,7 @@ def generate_dashboard( # noqa: C901
|
||||
|
||||
# Convert to our response format
|
||||
from superset.mcp_service.dashboard.schemas import (
|
||||
_humanize_timestamp,
|
||||
serialize_chart_summary,
|
||||
serialize_tag_object,
|
||||
)
|
||||
@@ -418,6 +428,10 @@ def generate_dashboard( # noqa: C901
|
||||
published=dashboard.published,
|
||||
created_on=dashboard.created_on,
|
||||
changed_on=dashboard.changed_on,
|
||||
created_on_humanized=_humanize_timestamp(dashboard.created_on),
|
||||
changed_on_humanized=_humanize_timestamp(dashboard.changed_on),
|
||||
created_by=dashboard.created_by_name or None,
|
||||
changed_by=dashboard.changed_by_name or None,
|
||||
uuid=str(dashboard.uuid) if dashboard.uuid else None,
|
||||
url=f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/",
|
||||
chart_count=len(request.chart_ids),
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any, Awaitable, Callable, Dict, Protocol, Sequence
|
||||
@@ -245,11 +246,20 @@ class LoggingMiddleware(Middleware):
|
||||
)
|
||||
tool_name = getattr(context.message, "name", None)
|
||||
|
||||
mcp_call_id = secrets.token_hex(16)
|
||||
context.mcp_call_id = mcp_call_id
|
||||
start_time = time.time()
|
||||
success = False
|
||||
try:
|
||||
result = await call_next(context)
|
||||
success = not self._is_error_response(result)
|
||||
if isinstance(result, ToolResult):
|
||||
existing_meta = result.meta or {}
|
||||
result = ToolResult(
|
||||
content=result.content,
|
||||
meta={**existing_meta, "mcp_call_id": mcp_call_id},
|
||||
structured_content=result.structured_content,
|
||||
)
|
||||
return result
|
||||
except Exception:
|
||||
success = False
|
||||
@@ -265,6 +275,7 @@ class LoggingMiddleware(Middleware):
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"mcp_call_id": mcp_call_id,
|
||||
"tool": tool_name,
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
@@ -278,7 +289,7 @@ class LoggingMiddleware(Middleware):
|
||||
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",
|
||||
"success=%s, mcp_call_id=%s",
|
||||
tool_name,
|
||||
agent_id,
|
||||
user_id,
|
||||
@@ -288,6 +299,7 @@ class LoggingMiddleware(Middleware):
|
||||
dataset_id,
|
||||
duration_ms,
|
||||
success,
|
||||
mcp_call_id,
|
||||
)
|
||||
|
||||
async def on_message(
|
||||
@@ -391,8 +403,10 @@ class StructuredContentStripperMiddleware(Middleware):
|
||||
# unhandled exception — including ToolError from
|
||||
# GlobalErrorHandlerMiddleware, ValueError, TypeError, etc. —
|
||||
# will cause encoding failures on the wire.
|
||||
mcp_call_id = getattr(context, "mcp_call_id", None)
|
||||
return ToolResult(
|
||||
content=[mt.TextContent(type="text", text=f"Error: {e}")],
|
||||
meta={"mcp_call_id": mcp_call_id} if mcp_call_id else None,
|
||||
)
|
||||
if isinstance(result, ToolResult) and result.structured_content is not None:
|
||||
result = ToolResult(content=result.content, meta=result.meta)
|
||||
|
||||
@@ -50,6 +50,7 @@ from superset.mcp_service.utils.oauth2_utils import (
|
||||
build_oauth2_redirect_message,
|
||||
OAUTH2_CONFIG_ERROR_MESSAGE,
|
||||
)
|
||||
from superset.sql.parse import SQLScript
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -117,7 +118,50 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
|
||||
error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR.value,
|
||||
)
|
||||
|
||||
# 2. Build QueryOptions and execute query
|
||||
# 2. Block destructive DDL (DROP, TRUNCATE, ALTER)
|
||||
# Fail-closed: if parsing fails, block the query rather than
|
||||
# allowing potentially destructive SQL to bypass the check.
|
||||
# Render Jinja2 templates first so templated SQL can be parsed.
|
||||
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
|
||||
try:
|
||||
sql_to_check = request.sql
|
||||
if request.template_params:
|
||||
from superset.jinja_context import get_template_processor
|
||||
|
||||
tp = get_template_processor(database=database)
|
||||
sql_to_check = tp.process_template(
|
||||
request.sql, **request.template_params
|
||||
)
|
||||
|
||||
script = SQLScript(sql_to_check, database.db_engine_spec.engine)
|
||||
if script.has_destructive():
|
||||
await ctx.error(
|
||||
"Destructive DDL blocked: sql_preview=%r" % sql_preview
|
||||
)
|
||||
return ExecuteSqlResponse(
|
||||
success=False,
|
||||
error=(
|
||||
"Destructive DDL statements (DROP, TRUNCATE, ALTER) "
|
||||
"are not allowed through MCP. Use the Superset SQL "
|
||||
"Lab UI for administrative database operations."
|
||||
),
|
||||
error_type=SupersetErrorType.DML_NOT_ALLOWED_ERROR.value,
|
||||
)
|
||||
except Exception as parse_err:
|
||||
await ctx.error(
|
||||
"DDL pre-check failed to parse SQL, blocking query: %s"
|
||||
% str(parse_err)
|
||||
)
|
||||
return ExecuteSqlResponse(
|
||||
success=False,
|
||||
error=(
|
||||
"SQL could not be parsed for security validation. "
|
||||
"Please check your SQL syntax and try again."
|
||||
),
|
||||
error_type=SupersetErrorType.INVALID_SQL_ERROR.value,
|
||||
)
|
||||
|
||||
# 3. Build QueryOptions and execute query
|
||||
cache_opts = CacheOptions(force_refresh=True) if request.force_refresh else None
|
||||
options = QueryOptions(
|
||||
catalog=request.catalog,
|
||||
@@ -129,11 +173,11 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
|
||||
cache=cache_opts,
|
||||
)
|
||||
|
||||
# 3. Execute query
|
||||
# 4. Execute query
|
||||
with event_logger.log_context(action="mcp.execute_sql.query_execution"):
|
||||
result = database.execute(request.sql, options)
|
||||
|
||||
# 4. Convert to MCP response format
|
||||
# 5. Convert to MCP response format
|
||||
with event_logger.log_context(action="mcp.execute_sql.response_conversion"):
|
||||
response = _convert_to_response(result)
|
||||
|
||||
|
||||
@@ -241,6 +241,14 @@ class GenerateBugReportRequest(BaseModel):
|
||||
"PII and secrets are sanitized before being written to the report."
|
||||
),
|
||||
)
|
||||
mcp_call_id: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional MCP call ID from a previous tool invocation. "
|
||||
"When provided, it will be included in the bug report "
|
||||
"for server-side log correlation."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class GenerateBugReportResponse(BaseModel):
|
||||
|
||||
@@ -212,6 +212,7 @@ def _format_report(
|
||||
environment: dict[str, str],
|
||||
user_context: dict[str, Any],
|
||||
timestamp: str,
|
||||
mcp_call_id: str | None = None,
|
||||
) -> str:
|
||||
"""Render the final markdown report."""
|
||||
lines: list[str] = [
|
||||
@@ -224,6 +225,10 @@ def _format_report(
|
||||
f"- **Platform:** {environment['platform']}",
|
||||
f"- **User ID:** {user_context['user_id']}",
|
||||
f"- **Roles:** {', '.join(user_context['roles']) or 'none'}",
|
||||
]
|
||||
if mcp_call_id:
|
||||
lines.append(f"- **MCP Call ID:** {mcp_call_id}")
|
||||
lines += [
|
||||
"",
|
||||
"## What the user was doing",
|
||||
f"- **MCP tool:** {sanitized.get('tool_name') or 'not provided'}",
|
||||
@@ -316,6 +321,7 @@ async def generate_bug_report(
|
||||
environment=environment,
|
||||
user_context=user_context,
|
||||
timestamp=timestamp,
|
||||
mcp_call_id=request.mcp_call_id,
|
||||
)
|
||||
|
||||
return GenerateBugReportResponse(
|
||||
|
||||
@@ -53,7 +53,11 @@ from superset_core.queries.models import (
|
||||
)
|
||||
|
||||
from superset import security_manager
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.exceptions import (
|
||||
SupersetException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
)
|
||||
from superset.explorables.base import TimeGrainDict
|
||||
from superset.jinja_context import BaseTemplateProcessor, get_template_processor
|
||||
from superset.models.helpers import (
|
||||
@@ -99,6 +103,14 @@ class SqlTablesMixin: # pylint: disable=too-few-public-methods
|
||||
)
|
||||
except (SupersetSecurityException, SupersetParseError, TemplateError):
|
||||
return []
|
||||
except SupersetException as ex:
|
||||
# Jinja macros such as ``{{ dataset(id) }}`` or ``{{ metric(...) }}``
|
||||
# may reference resources that no longer exist (e.g. a deleted
|
||||
# dataset). Surfacing the failure here would break list endpoints
|
||||
# that include ``sql_tables`` in their payload, hiding every saved
|
||||
# query from the user. Treat it as a parse failure instead.
|
||||
logger.warning("Unable to extract tables from SQL via Jinja: %s", ex)
|
||||
return []
|
||||
|
||||
|
||||
class Query(
|
||||
|
||||
+78
-1
@@ -49,13 +49,14 @@ from superset.databases.filters import DatabaseFilter
|
||||
from superset.exceptions import SupersetException
|
||||
from superset.extensions import event_logger
|
||||
from superset.reports.filters import ReportScheduleAllTextFilter, ReportScheduleFilter
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.reports.models import ReportCreationMethod, ReportSchedule
|
||||
from superset.reports.schemas import (
|
||||
get_delete_ids_schema,
|
||||
get_slack_channels_schema,
|
||||
openapi_spec_methods_override,
|
||||
ReportSchedulePostSchema,
|
||||
ReportSchedulePutSchema,
|
||||
ReportScheduleSubscribeSchema,
|
||||
)
|
||||
from superset.utils.slack import get_channels_with_search
|
||||
from superset.views.base_api import (
|
||||
@@ -82,6 +83,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
RouteMethod.RELATED,
|
||||
"bulk_delete",
|
||||
"slack_channels", # not using RouteMethod since locally defined
|
||||
"subscribe",
|
||||
}
|
||||
class_permission_name = "ReportSchedule"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
@@ -198,6 +200,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
edit_columns = add_columns
|
||||
add_model_schema = ReportSchedulePostSchema()
|
||||
edit_model_schema = ReportSchedulePutSchema()
|
||||
subscribe_schema = ReportScheduleSubscribeSchema()
|
||||
|
||||
order_columns = [
|
||||
"active",
|
||||
@@ -318,6 +321,80 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/subscribe", methods=("POST",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@permission_name("subscribe")
|
||||
@requires_json
|
||||
def subscribe(self) -> Response:
|
||||
"""Subscribe the current user to a chart or dashboard report.
|
||||
---
|
||||
post:
|
||||
summary: Subscribe to a chart or dashboard report
|
||||
description: >-
|
||||
Creates a report schedule locked to the authenticated user's email.
|
||||
``creation_method`` is derived server-side from the payload
|
||||
(chart → charts, dashboard → dashboards). ``recipients`` are not
|
||||
accepted and are always set to the requesting user's email address.
|
||||
requestBody:
|
||||
description: Report schedule subscription schema
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
|
||||
responses:
|
||||
201:
|
||||
description: Report schedule subscription created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
result:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
item = self.subscribe_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
# Derive creation_method server-side from the payload
|
||||
if item.get("dashboard") is not None:
|
||||
item["creation_method"] = ReportCreationMethod.DASHBOARDS
|
||||
elif item.get("chart") is not None:
|
||||
item["creation_method"] = ReportCreationMethod.CHARTS
|
||||
else:
|
||||
return self.response_400(
|
||||
message={"_schema": ["Either chart or dashboard is required"]}
|
||||
)
|
||||
|
||||
try:
|
||||
new_model = CreateReportScheduleCommand(item).run()
|
||||
return self.response(201, id=new_model.id, result=item)
|
||||
except ReportScheduleNotFoundError as ex:
|
||||
return self.response_400(message=str(ex))
|
||||
except ReportScheduleInvalidError as ex:
|
||||
return self.response_422(message=ex.normalized_messages())
|
||||
except ReportScheduleCreateFailedError as ex:
|
||||
logger.error(
|
||||
"Error creating report schedule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/", methods=("POST",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
|
||||
@@ -305,6 +305,30 @@ class ReportSchedulePostSchema(Schema):
|
||||
)
|
||||
|
||||
|
||||
class ReportScheduleSubscribeSchema(ReportSchedulePostSchema):
|
||||
"""Schema for creating a chart/dashboard subscription.
|
||||
|
||||
``recipients`` and ``creation_method`` are excluded — both are set
|
||||
server-side: recipients are locked to the authenticated user's email,
|
||||
and creation_method is derived from the presence of ``chart`` or
|
||||
``dashboard`` in the payload.
|
||||
|
||||
``type`` is restricted to ``Report`` — alert schedules cannot be
|
||||
created through the subscribe endpoint.
|
||||
"""
|
||||
|
||||
type = fields.String(
|
||||
metadata={"description": type_description},
|
||||
allow_none=False,
|
||||
required=True,
|
||||
validate=validate.OneOf(choices=[ReportScheduleType.REPORT.value]),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
exclude = ("recipients", "creation_method", "owners")
|
||||
unknown = EXCLUDE
|
||||
|
||||
|
||||
class ReportSchedulePutSchema(Schema):
|
||||
type = fields.String(
|
||||
metadata={"description": type_description},
|
||||
|
||||
@@ -439,6 +439,14 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_destructive(self) -> bool:
|
||||
"""
|
||||
Check if the statement is destructive DDL (DROP, TRUNCATE, ALTER).
|
||||
|
||||
:return: True if the statement is destructive DDL.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def optimize(self) -> BaseSQLStatement[InternalRepresentation]:
|
||||
"""
|
||||
Return optimized statement.
|
||||
@@ -719,6 +727,31 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
|
||||
return False
|
||||
|
||||
def is_destructive(self) -> bool:
|
||||
"""
|
||||
Check if the statement is destructive DDL (DROP, TRUNCATE, ALTER).
|
||||
|
||||
Unlike ``is_mutating()``, this excludes non-destructive DML
|
||||
(INSERT, UPDATE, DELETE, MERGE) and CREATE.
|
||||
|
||||
:return: True if the statement is destructive DDL.
|
||||
"""
|
||||
destructive_nodes = (
|
||||
exp.Drop,
|
||||
exp.TruncateTable,
|
||||
exp.Alter,
|
||||
)
|
||||
|
||||
for node_type in destructive_nodes:
|
||||
if self._parsed.find(node_type):
|
||||
return True
|
||||
|
||||
# Handle ALTER parsed as Command (Oracle, MS SQL dialects)
|
||||
if isinstance(self._parsed, exp.Command) and self._parsed.name == "ALTER":
|
||||
return True # pragma: no cover
|
||||
|
||||
return False
|
||||
|
||||
def format(self, comments: bool = True) -> str:
|
||||
"""
|
||||
Pretty-format the SQL statement.
|
||||
@@ -1175,6 +1208,18 @@ class KustoKQLStatement(BaseSQLStatement[str]):
|
||||
"""
|
||||
return self._parsed.startswith(".") and not self._parsed.startswith(".show")
|
||||
|
||||
def is_destructive(self) -> bool:
|
||||
"""
|
||||
Check if the statement is destructive DDL.
|
||||
|
||||
Kusto KQL uses dot-commands for management operations. Destructive
|
||||
operations start with ``.drop`` or ``.alter``.
|
||||
|
||||
:return: True if the statement is destructive DDL.
|
||||
"""
|
||||
lower = self._parsed.lower()
|
||||
return lower.startswith(".drop") or lower.startswith(".alter")
|
||||
|
||||
def optimize(self) -> KustoKQLStatement:
|
||||
"""
|
||||
Return optimized statement.
|
||||
@@ -1321,6 +1366,14 @@ class SQLScript:
|
||||
"""
|
||||
return any(statement.is_mutating() for statement in self.statements)
|
||||
|
||||
def has_destructive(self) -> bool:
|
||||
"""
|
||||
Check if the script contains destructive DDL (DROP, TRUNCATE, ALTER).
|
||||
|
||||
:return: True if any statement is destructive DDL.
|
||||
"""
|
||||
return any(statement.is_destructive() for statement in self.statements)
|
||||
|
||||
def optimize(self) -> SQLScript:
|
||||
"""
|
||||
Return optimized script.
|
||||
|
||||
@@ -1237,6 +1237,76 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
||||
chart["id"] for chart in data_by_name["result"]
|
||||
), set(chart.id for chart in expected_charts) # noqa: C401
|
||||
|
||||
def test_get_charts_changed_on_delta_humanized_sort_monotonic(self):
|
||||
"""Regression for #27500: sorting the chart list by
|
||||
`changed_on_delta_humanized` desc must yield results whose underlying
|
||||
`changed_on` timestamps are monotonically non-increasing. The original
|
||||
report shows the humanized column visually out of order, suggesting
|
||||
the sort key didn't actually reflect the timestamp."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
admin = self.get_user("admin")
|
||||
# Insert two charts with distinct changed_on timestamps. Use raw UPDATE
|
||||
# to force the values since assignment alone can be overridden by the
|
||||
# before-update hook.
|
||||
chart_older = self.insert_chart(
|
||||
"regression_27500_older", [admin.id], 1, description="z"
|
||||
)
|
||||
chart_newer = self.insert_chart(
|
||||
"regression_27500_newer", [admin.id], 1, description="z"
|
||||
)
|
||||
# Use timestamps whose humanized strings sort DIFFERENTLY from their
|
||||
# real timestamps under a naive lexical sort. "3 hours ago" vs
|
||||
# "5 hours ago": lexical-desc puts "5..." first (older), but
|
||||
# timestamp-desc must put "3..." first (newer). If the API ever
|
||||
# accidentally sorts by the humanized text instead of the column,
|
||||
# this test fails. (Pairs like "now"/"2 days ago" don't discriminate
|
||||
# because 'n' > '2' lexically agrees with newest-first.)
|
||||
now = datetime.utcnow()
|
||||
chart_older.changed_on = now - timedelta(hours=5)
|
||||
chart_newer.changed_on = now - timedelta(hours=3)
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
arguments = {
|
||||
"order_column": "changed_on_delta_humanized",
|
||||
"order_direction": "desc",
|
||||
"filters": [
|
||||
{
|
||||
"col": "slice_name",
|
||||
"opr": "sw",
|
||||
"value": "regression_27500_",
|
||||
}
|
||||
],
|
||||
}
|
||||
uri = f"api/v1/chart/?q={rison.dumps(arguments)}"
|
||||
rv = self.get_assert_metric(uri, "get_list")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
results = data["result"]
|
||||
assert len(results) >= 2, f"expected at least 2 results, got {results}"
|
||||
|
||||
# The two inserted charts should appear in newest-first order.
|
||||
indices = {
|
||||
row["slice_name"]: i
|
||||
for i, row in enumerate(results)
|
||||
if row["slice_name"].startswith("regression_27500_")
|
||||
}
|
||||
ordering = [
|
||||
row["slice_name"]
|
||||
for row in results
|
||||
if row["slice_name"].startswith("regression_27500_")
|
||||
]
|
||||
assert (
|
||||
indices["regression_27500_newer"] < indices["regression_27500_older"]
|
||||
), f"changed_on_delta_humanized desc sort is wrong: {ordering}; see #27500"
|
||||
finally:
|
||||
db.session.delete(chart_older)
|
||||
db.session.delete(chart_newer)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_charts_changed_on(self):
|
||||
"""
|
||||
Dashboard API: Test get charts changed on
|
||||
|
||||
@@ -337,7 +337,8 @@ class TestReportSchedulesApi(SupersetTestCase):
|
||||
assert rv.status_code == 200
|
||||
assert "can_read" in data["permissions"]
|
||||
assert "can_write" in data["permissions"]
|
||||
assert len(data["permissions"]) == 2
|
||||
assert "can_subscribe" in data["permissions"]
|
||||
assert len(data["permissions"]) == 3
|
||||
|
||||
@pytest.mark.usefixtures("create_report_schedules")
|
||||
def test_get_report_schedule_not_found(self):
|
||||
|
||||
@@ -152,3 +152,53 @@ def test_time_grain_validation_with_config_addons(app_context: None) -> None:
|
||||
}
|
||||
result = schema.load(custom_data)
|
||||
assert result["time_grain"] == "PT10M"
|
||||
|
||||
|
||||
def test_post_processing_operation_validates_options(app_context: None) -> None:
|
||||
"""options are validated against the operation's option schema (leniently)."""
|
||||
from superset.charts.schemas import ChartDataPostProcessingOperationSchema
|
||||
|
||||
schema = ChartDataPostProcessingOperationSchema()
|
||||
|
||||
# Valid prophet options load.
|
||||
schema.load(
|
||||
{
|
||||
"operation": "prophet",
|
||||
"options": {
|
||||
"time_grain": "P1D",
|
||||
"periods": 7,
|
||||
"confidence_interval": 0.8,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Out-of-range confidence_interval (must be 0-1) on a declared field is
|
||||
# rejected.
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load(
|
||||
{
|
||||
"operation": "prophet",
|
||||
"options": {
|
||||
"time_grain": "P1D",
|
||||
"periods": 7,
|
||||
"confidence_interval": 2.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert "options" in exc_info.value.messages
|
||||
|
||||
# Extra/unknown keys are tolerated (lenient validation).
|
||||
schema.load(
|
||||
{
|
||||
"operation": "prophet",
|
||||
"options": {
|
||||
"time_grain": "P1D",
|
||||
"periods": 7,
|
||||
"confidence_interval": 0.8,
|
||||
"some_future_option": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# An operation without a dedicated schema accepts arbitrary options.
|
||||
schema.load({"operation": "flatten", "options": {"anything": [1, 2, 3]}})
|
||||
|
||||
@@ -1478,3 +1478,46 @@ def test_success_state_report_sends_and_logs_success(
|
||||
ReportState.SUCCESS,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
def test_get_url_for_csv_uses_post_processed_type(
|
||||
app: SupersetApp,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Regression for #25538: when an alert/report generates a CSV for a
|
||||
chart, the URL must request type=POST_PROCESSED so the chart's saved
|
||||
filters (including time-range filters) are applied. The original report
|
||||
described a chart with a "last 30 days" filter that returned only 14
|
||||
rows in the UI, but the alert CSV came back with 219 rows (the entire
|
||||
unfiltered table).
|
||||
|
||||
POST_PROCESSED is the marker that propagates the chart's query_context
|
||||
-- including its time filter -- through to the CSV renderer. A
|
||||
regression that switched to FULL or RAW would replicate the original
|
||||
bug.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from superset.commands.report.execute import BaseReportState
|
||||
from superset.common.chart_data import ChartDataResultFormat
|
||||
|
||||
app.config.update({"ALERT_REPORTS_EXECUTORS": {}})
|
||||
|
||||
report_schedule = create_report_schedule(mocker)
|
||||
report_schedule.force_screenshot = False
|
||||
report_schedule.chart_id = report_schedule.chart.id
|
||||
|
||||
state = BaseReportState(
|
||||
report_schedule=report_schedule,
|
||||
scheduled_dttm=datetime.now(),
|
||||
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
|
||||
)
|
||||
|
||||
url = state._get_url(result_format=ChartDataResultFormat.CSV)
|
||||
|
||||
assert "format=csv" in url.lower(), f"expected csv format in URL: {url}"
|
||||
assert "type=post_processed" in url.lower(), (
|
||||
f"CSV report URL must use type=post_processed so chart filters "
|
||||
f"(incl. time filters) are applied; got: {url}; see issue #25538"
|
||||
)
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Unit tests for UpdateReportScheduleCommand.validate() database invariants."""
|
||||
"""Unit tests for UpdateReportScheduleCommand.validate()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
@@ -30,7 +30,11 @@ from superset.commands.report.exceptions import (
|
||||
from superset.commands.report.update import UpdateReportScheduleCommand
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.reports.models import ReportScheduleType, ReportState
|
||||
from superset.reports.models import (
|
||||
ReportCreationMethod,
|
||||
ReportScheduleType,
|
||||
ReportState,
|
||||
)
|
||||
|
||||
|
||||
def _make_model(
|
||||
@@ -38,10 +42,12 @@ def _make_model(
|
||||
*,
|
||||
model_type: ReportScheduleType | str,
|
||||
database_id: int | None,
|
||||
creation_method: ReportCreationMethod = ReportCreationMethod.ALERTS_REPORTS,
|
||||
) -> Mock:
|
||||
model = mocker.Mock()
|
||||
model.type = model_type
|
||||
model.database_id = database_id
|
||||
model.creation_method = creation_method
|
||||
model.name = "test_schedule"
|
||||
model.crontab = "0 9 * * *"
|
||||
model.last_state = "noop"
|
||||
@@ -257,6 +263,142 @@ def test_report_to_alert_with_db_accepted(mocker: MockerFixture) -> None:
|
||||
cmd.validate() # should not raise
|
||||
|
||||
|
||||
# --- Recipient enforcement for chart/dashboard reports ---
|
||||
|
||||
_PATCH_GET_USER_EMAIL = "superset.commands.report.update.get_user_email"
|
||||
|
||||
|
||||
def test_chart_report_update_recipient_overridden_with_owner_email(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Updating recipients on a chart report always locks them to the owner's email."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.CHARTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
data = {
|
||||
"recipients": [
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "other@example.com"},
|
||||
}
|
||||
]
|
||||
}
|
||||
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
recipients = cmd._properties["recipients"]
|
||||
assert len(recipients) == 1
|
||||
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
|
||||
|
||||
|
||||
def test_dashboard_report_update_recipient_overridden_with_owner_email(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Updating recipients on a dashboard report locks them to the owner's email."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.DASHBOARDS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
data = {
|
||||
"recipients": [
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "other@example.com"},
|
||||
}
|
||||
]
|
||||
}
|
||||
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
recipients = cmd._properties["recipients"]
|
||||
assert len(recipients) == 1
|
||||
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
|
||||
|
||||
|
||||
def test_alerts_reports_update_recipient_not_overridden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Recipients on admin-created alerts/reports are not modified on update."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.ALERTS_REPORTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
original_recipient = {
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "team@example.com"},
|
||||
}
|
||||
cmd = UpdateReportScheduleCommand(
|
||||
model_id=1, data={"recipients": [original_recipient]}
|
||||
)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
assert (
|
||||
cmd._properties["recipients"][0]["recipient_config_json"]["target"]
|
||||
== "team@example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_chart_report_update_no_recipients_in_payload_unchanged(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""If recipients are not in the update payload, nothing is changed."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.CHARTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
cmd = UpdateReportScheduleCommand(model_id=1, data={"name": "new name"})
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
assert "recipients" not in cmd._properties
|
||||
|
||||
|
||||
def test_chart_report_update_no_user_email_raises(mocker: MockerFixture) -> None:
|
||||
"""Update fails with a validation error when the user has no email address."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.CHARTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
cmd = UpdateReportScheduleCommand(
|
||||
model_id=1,
|
||||
data={
|
||||
"recipients": [
|
||||
{"type": "Email", "recipient_config_json": {"target": "x@y.com"}}
|
||||
]
|
||||
},
|
||||
)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value=None):
|
||||
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
||||
cmd.validate()
|
||||
|
||||
messages = _get_validation_messages(exc_info)
|
||||
assert "recipients" in messages
|
||||
|
||||
|
||||
# --- Deactivation state reset ---
|
||||
|
||||
|
||||
|
||||
@@ -137,3 +137,51 @@ def test_get_virtual_table_metadata_multiple(mocker: MockerFixture) -> None:
|
||||
with pytest.raises(SupersetSecurityException) as excinfo:
|
||||
get_virtual_table_metadata(dataset)
|
||||
assert str(excinfo.value) == "Only single queries supported"
|
||||
|
||||
|
||||
def test_get_virtual_table_metadata_renders_jinja(mocker: MockerFixture) -> None:
|
||||
"""Regression for #25839: Jinja templates in a virtual dataset's SQL must
|
||||
be rendered via the template processor before SQL parsing. Otherwise the
|
||||
raw Jinja tokens reach sqlglot and the parser rejects them as a syntax
|
||||
error (the user-visible symptom is "Invalid SQL" when clicking
|
||||
"SYNC COLUMNS FROM SOURCE" on a dataset that uses {{ from_dttm }} etc.).
|
||||
"""
|
||||
mock_get_columns_description = mocker.patch(
|
||||
"superset.connectors.sqla.utils.get_columns_description",
|
||||
return_value=[{"name": "rendered_col", "type": "INTEGER"}],
|
||||
)
|
||||
|
||||
raw_sql = "SELECT * FROM tbl WHERE ts > '{{ from_dttm }}'"
|
||||
rendered_sql = "SELECT * FROM tbl WHERE ts > '2024-01-01 00:00:00'"
|
||||
|
||||
dataset = mocker.MagicMock(sql=raw_sql)
|
||||
dataset.database.db_engine_spec.engine = "postgresql"
|
||||
dataset.template_params_dict = {}
|
||||
dataset.get_template_processor().process_template.return_value = rendered_sql
|
||||
|
||||
# If Jinja rendering is skipped, sqlglot tries to parse the raw {{ ... }}
|
||||
# and raises SupersetGenericDBErrorException / SupersetParseError.
|
||||
assert get_virtual_table_metadata(dataset) == [
|
||||
{"name": "rendered_col", "type": "INTEGER"}
|
||||
]
|
||||
|
||||
# The template processor MUST have been called with the raw SQL (the
|
||||
# whole point of the bug fix). A future regression that re-introduces
|
||||
# the "Jinja not rendered" path would either skip this call or call it
|
||||
# with the wrong input.
|
||||
dataset.get_template_processor().process_template.assert_any_call(
|
||||
raw_sql, **dataset.template_params_dict
|
||||
)
|
||||
|
||||
# End-to-end guard: the rendered SQL must reach get_columns_description,
|
||||
# not the raw Jinja string. A regression where rendering is used for
|
||||
# parsing only and the raw SQL leaks downstream would pass the
|
||||
# process_template assertion above but fail this one.
|
||||
call_args = mock_get_columns_description.call_args
|
||||
assert call_args is not None, "get_columns_description was never called"
|
||||
passed_query = call_args.kwargs.get("query")
|
||||
if passed_query is None and call_args.args:
|
||||
passed_query = call_args.args[-1]
|
||||
assert passed_query == rendered_sql, (
|
||||
f"get_columns_description received unrendered SQL: {passed_query!r}"
|
||||
)
|
||||
|
||||
@@ -123,6 +123,251 @@ def test_update_native_filter_config_scope_excluded():
|
||||
}
|
||||
|
||||
|
||||
def test_update_native_filter_config_preserves_rootpath_and_remaps_excluded():
|
||||
"""
|
||||
Regression guard for #19944: a native filter's ``scope`` has two parts that
|
||||
must both survive an export/import roundtrip:
|
||||
|
||||
- ``rootPath`` controls *which* dashboard sections (tabs/rows) the filter
|
||||
applies to. It uses position keys (``ROOT_ID``, ``TAB-xxx``), not chart
|
||||
IDs, so ``update_id_refs`` must leave it untouched.
|
||||
- ``excluded`` is a list of chart IDs the filter does NOT apply to within
|
||||
its rootPath. Those IDs must be remapped to destination-env IDs.
|
||||
|
||||
The original bug report — "filters are automatically applied to all charts,
|
||||
even if a different scoping was defined before the export" — describes a
|
||||
rootPath silently being collapsed back to ``["ROOT_ID"]`` (i.e. "apply
|
||||
everywhere"). This test pins the post-refactor contract: the import path
|
||||
must not mutate or drop ``rootPath``.
|
||||
"""
|
||||
from superset.commands.dashboard.importers.v1.utils import update_id_refs
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"position": {
|
||||
"CHART1": {
|
||||
"id": "CHART1",
|
||||
"meta": {"chartId": 101, "uuid": "uuid1"},
|
||||
"type": "CHART",
|
||||
},
|
||||
"CHART2": {
|
||||
"id": "CHART2",
|
||||
"meta": {"chartId": 102, "uuid": "uuid2"},
|
||||
"type": "CHART",
|
||||
},
|
||||
"CHART3": {
|
||||
"id": "CHART3",
|
||||
"meta": {"chartId": 103, "uuid": "uuid3"},
|
||||
"type": "CHART",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-region",
|
||||
"name": "Region",
|
||||
"scope": {
|
||||
# Filter applies only to charts under TAB-revenue,
|
||||
# except chart 102 which is explicitly excluded.
|
||||
"rootPath": ["TAB-revenue"],
|
||||
"excluded": [102],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "NATIVE_FILTER-product",
|
||||
"name": "Product",
|
||||
"scope": {
|
||||
# Different filter, different rootPath; must not be
|
||||
# cross-contaminated with the first filter's scope.
|
||||
"rootPath": ["TAB-inventory", "TAB-revenue"],
|
||||
"excluded": [101, 103],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
chart_ids = {"uuid1": 1, "uuid2": 2, "uuid3": 3}
|
||||
dataset_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
fixed = update_id_refs(config, chart_ids, dataset_info)
|
||||
filters = fixed["metadata"]["native_filter_configuration"]
|
||||
|
||||
# rootPath uses position keys, not chart IDs — must pass through unchanged.
|
||||
assert filters[0]["scope"]["rootPath"] == ["TAB-revenue"]
|
||||
assert filters[1]["scope"]["rootPath"] == ["TAB-inventory", "TAB-revenue"]
|
||||
|
||||
# excluded uses chart IDs — must be remapped to destination-env IDs.
|
||||
assert filters[0]["scope"]["excluded"] == [2]
|
||||
assert filters[1]["scope"]["excluded"] == [1, 3]
|
||||
|
||||
|
||||
def test_update_native_filter_config_default_rootpath_preserved():
|
||||
"""
|
||||
The "apply everywhere" default — ``rootPath: ["ROOT_ID"]`` — must also
|
||||
survive untouched. A regression that special-cased this value (e.g. by
|
||||
deleting it) would silently change "apply everywhere" into "apply nowhere"
|
||||
on import, since downstream consumers treat a missing rootPath as empty
|
||||
rather than as the default.
|
||||
"""
|
||||
from superset.commands.dashboard.importers.v1.utils import update_id_refs
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"position": {
|
||||
"CHART1": {
|
||||
"id": "CHART1",
|
||||
"meta": {"chartId": 101, "uuid": "uuid1"},
|
||||
"type": "CHART",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-global",
|
||||
"name": "Global",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
chart_ids = {"uuid1": 1}
|
||||
dataset_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
fixed = update_id_refs(config, chart_ids, dataset_info)
|
||||
scope = fixed["metadata"]["native_filter_configuration"][0]["scope"]
|
||||
|
||||
assert scope["rootPath"] == ["ROOT_ID"]
|
||||
assert scope["excluded"] == []
|
||||
|
||||
|
||||
def test_update_id_refs_remaps_charts_in_scope():
|
||||
"""
|
||||
Regression for #26338: ``chartsInScope`` on a native filter holds chart
|
||||
IDs and must be remapped from source-env IDs to destination-env IDs
|
||||
during import.
|
||||
|
||||
The export side already converts ``chartsInScope`` IDs to UUIDs (see
|
||||
``export_example.py:325``). The import side must symmetrically convert
|
||||
them back to the destination environment's chart IDs. Without that
|
||||
remap, the field carries stale source IDs into the imported dashboard
|
||||
and breaks ``filtersInScope`` / ``filtersOutScope`` computation —
|
||||
filters end up applied to the wrong charts (or none at all).
|
||||
"""
|
||||
from superset.commands.dashboard.importers.v1.utils import update_id_refs
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"position": {
|
||||
"CHART1": {
|
||||
"id": "CHART1",
|
||||
"meta": {"chartId": 101, "uuid": "uuid1"},
|
||||
"type": "CHART",
|
||||
},
|
||||
"CHART2": {
|
||||
"id": "CHART2",
|
||||
"meta": {"chartId": 102, "uuid": "uuid2"},
|
||||
"type": "CHART",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-region",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
# chartsInScope contains source-env chart IDs.
|
||||
"chartsInScope": [101, 102, 103],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
chart_ids = {"uuid1": 1, "uuid2": 2}
|
||||
dataset_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
fixed = update_id_refs(config, chart_ids, dataset_info)
|
||||
filter_config = fixed["metadata"]["native_filter_configuration"][0]
|
||||
|
||||
# Resolved IDs are remapped; unknown IDs (103) are dropped rather than
|
||||
# left to silently bind to whatever chart owns that integer in the
|
||||
# destination environment.
|
||||
assert filter_config["chartsInScope"] == [1, 2]
|
||||
|
||||
|
||||
def test_update_id_refs_remaps_cross_filter_charts_in_scope():
|
||||
"""
|
||||
Companion to test_update_id_refs_remaps_charts_in_scope. Cross-filter
|
||||
config also stores ``chartsInScope`` (under ``crossFilters`` per chart)
|
||||
and must be remapped on import for the same reason.
|
||||
"""
|
||||
from superset.commands.dashboard.importers.v1.utils import update_id_refs
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"position": {
|
||||
"CHART1": {
|
||||
"id": "CHART1",
|
||||
"meta": {"chartId": 101, "uuid": "uuid1"},
|
||||
"type": "CHART",
|
||||
},
|
||||
"CHART2": {
|
||||
"id": "CHART2",
|
||||
"meta": {"chartId": 102, "uuid": "uuid2"},
|
||||
"type": "CHART",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
"chart_configuration": {
|
||||
"101": {
|
||||
"id": 101,
|
||||
"crossFilters": {
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [101, 102, 103],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
chart_ids = {"uuid1": 1, "uuid2": 2}
|
||||
dataset_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
fixed = update_id_refs(config, chart_ids, dataset_info)
|
||||
cross_filters = fixed["metadata"]["chart_configuration"]["1"]["crossFilters"]
|
||||
|
||||
assert cross_filters["chartsInScope"] == [1, 2]
|
||||
|
||||
|
||||
def test_update_id_refs_remaps_global_chart_configuration_charts_in_scope():
|
||||
"""
|
||||
Per-chart and native-filter ``chartsInScope`` are remapped by their own
|
||||
branches; ``global_chart_configuration.chartsInScope`` lives next to
|
||||
``global_chart_configuration.scope.excluded`` and needs the same treatment
|
||||
so the global cross-filter scope cache doesn't keep stale source-env IDs.
|
||||
"""
|
||||
from superset.commands.dashboard.importers.v1.utils import update_id_refs
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"position": {
|
||||
"CHART1": {
|
||||
"id": "CHART1",
|
||||
"meta": {"chartId": 101, "uuid": "uuid1"},
|
||||
"type": "CHART",
|
||||
},
|
||||
"CHART2": {
|
||||
"id": "CHART2",
|
||||
"meta": {"chartId": 102, "uuid": "uuid2"},
|
||||
"type": "CHART",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
"global_chart_configuration": {
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [101, 102, 103],
|
||||
},
|
||||
},
|
||||
}
|
||||
chart_ids = {"uuid1": 1, "uuid2": 2}
|
||||
dataset_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
fixed = update_id_refs(config, chart_ids, dataset_info)
|
||||
|
||||
assert fixed["metadata"]["global_chart_configuration"]["chartsInScope"] == [1, 2]
|
||||
|
||||
|
||||
def test_update_id_refs_cross_filter_chart_configuration_key_and_excluded_mapping():
|
||||
from superset.commands.dashboard.importers.v1.utils import update_id_refs
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy import column, types
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
|
||||
from sqlalchemy.engine.interfaces import Dialect
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
@@ -87,6 +87,8 @@ def test_convert_dttm(
|
||||
("TIME", types.Time, None, GenericDataType.TEMPORAL, True),
|
||||
# Boolean
|
||||
("BOOLEAN", types.Boolean, None, GenericDataType.BOOLEAN, False),
|
||||
# Interval (mapped to NUMERIC for chart rendering)
|
||||
("INTERVAL", INTERVAL, None, GenericDataType.NUMERIC, False),
|
||||
],
|
||||
)
|
||||
def test_get_column_spec(
|
||||
@@ -366,3 +368,38 @@ class TestRedshiftDetection:
|
||||
spec.update_params_from_encrypted_extra(database, params)
|
||||
|
||||
assert "pool_events" not in params
|
||||
|
||||
|
||||
def test_interval_type_mutator() -> None:
|
||||
"""
|
||||
DB Eng Specs (postgres): Test INTERVAL type mutator
|
||||
|
||||
INTERVAL values are converted to milliseconds so users can apply
|
||||
the built-in "DURATION" number format for human-readable display.
|
||||
"""
|
||||
mutator = spec.column_type_mutators[INTERVAL]
|
||||
|
||||
# Timedelta conversion — the only path psycopg2/psycopg3 actually
|
||||
# exercises. Result is in milliseconds for compatibility with the
|
||||
# DURATION formatter.
|
||||
td = timedelta(days=1, hours=2, minutes=30, seconds=45)
|
||||
assert mutator(td) == 95445000.0 # (1*86400 + 2*3600 + 30*60 + 45) * 1000
|
||||
|
||||
# Zero duration
|
||||
assert mutator(timedelta(0)) == 0.0
|
||||
|
||||
# Negative interval
|
||||
assert mutator(timedelta(days=-1)) == -86400000.0
|
||||
|
||||
# None preserves NULL semantics (not converted to 0)
|
||||
assert mutator(None) is None
|
||||
|
||||
# Unexpected non-timedelta types fall through to the defensive
|
||||
# `return None` (and emit a warning) rather than producing a
|
||||
# mixed-type column.
|
||||
assert mutator("1 day 02:30:45") is None
|
||||
assert mutator("P1DT2H30M45S") is None
|
||||
assert mutator(12345) is None
|
||||
assert mutator(True) is None
|
||||
assert mutator([1, 2, 3]) is None
|
||||
assert mutator({"days": 1}) is None
|
||||
|
||||
@@ -15,9 +15,88 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Unit tests for ascii_charts.py NaN/null value handling."""
|
||||
"""Unit tests for ASCII charts: trend analysis, NaN/null handling,
|
||||
and boolean skipping."""
|
||||
|
||||
from superset.mcp_service.chart.ascii_charts import generate_ascii_chart
|
||||
from superset.mcp_service.chart.ascii_charts import (
|
||||
_analyze_trend,
|
||||
_extract_time_series_data,
|
||||
generate_ascii_chart,
|
||||
)
|
||||
|
||||
|
||||
def test_analyze_trend_temporal_upward():
|
||||
values = [100.0, 110.0, 120.0, 130.0, 150.0]
|
||||
result = _analyze_trend(values, is_temporal=True)
|
||||
trend_line = result[0]
|
||||
assert "upward trend" in trend_line.lower()
|
||||
assert "categorical" not in trend_line.lower()
|
||||
|
||||
|
||||
def test_analyze_trend_temporal_downward():
|
||||
values = [200.0, 180.0, 150.0, 120.0, 100.0]
|
||||
result = _analyze_trend(values, is_temporal=True)
|
||||
trend_line = result[0]
|
||||
assert "downward trend" in trend_line.lower()
|
||||
|
||||
|
||||
def test_analyze_trend_temporal_stable():
|
||||
values = [100.0, 101.0, 100.5, 100.2, 100.8]
|
||||
result = _analyze_trend(values, is_temporal=True)
|
||||
trend_line = result[0]
|
||||
assert "stable" in trend_line.lower()
|
||||
|
||||
|
||||
def test_analyze_trend_categorical_skips_direction():
|
||||
values = [200.0, 180.0, 150.0, 120.0, 100.0]
|
||||
result = _analyze_trend(values, is_temporal=False)
|
||||
trend_line = result[0]
|
||||
assert "categorical" in trend_line.lower()
|
||||
assert "downward" not in trend_line.lower()
|
||||
assert "upward" not in trend_line.lower()
|
||||
|
||||
|
||||
def test_analyze_trend_categorical_keeps_range_and_volatility():
|
||||
values = [200.0, 180.0, 150.0, 120.0, 100.0]
|
||||
result = _analyze_trend(values, is_temporal=False)
|
||||
full_text = "\n".join(result)
|
||||
assert "Range:" in full_text
|
||||
assert "Volatility:" in full_text
|
||||
|
||||
|
||||
def test_analyze_trend_defaults_to_temporal():
|
||||
values = [100.0, 150.0, 200.0]
|
||||
result = _analyze_trend(values)
|
||||
trend_line = result[0]
|
||||
assert "categorical" not in trend_line.lower()
|
||||
assert "upward trend" in trend_line.lower()
|
||||
|
||||
|
||||
def test_extract_time_series_data_temporal_column():
|
||||
data = [
|
||||
{"date": "2024-01-01", "value": 100},
|
||||
{"date": "2024-02-01", "value": 200},
|
||||
]
|
||||
values, labels, is_temporal = _extract_time_series_data(data)
|
||||
assert values == [100, 200]
|
||||
assert is_temporal is True
|
||||
|
||||
|
||||
def test_extract_time_series_data_categorical_column():
|
||||
data = [
|
||||
{"country": "Argentina", "value": 500},
|
||||
{"country": "Brazil", "value": 300},
|
||||
]
|
||||
values, labels, is_temporal = _extract_time_series_data(data)
|
||||
assert values == [500, 300]
|
||||
assert is_temporal is False
|
||||
|
||||
|
||||
def test_extract_time_series_data_time_keyword_variants():
|
||||
for key in ["timestamp", "created_time", "month_name", "day_of_week", "year_end"]:
|
||||
data = [{key: "val", "metric": 42}]
|
||||
_, _, is_temporal = _extract_time_series_data(data)
|
||||
assert is_temporal is True, f"Expected temporal for column '{key}'"
|
||||
|
||||
|
||||
def test_bar_chart_with_null_values_does_not_raise() -> None:
|
||||
|
||||
@@ -423,6 +423,93 @@ class TestValidateAndCompileTier2:
|
||||
assert result.error_code == "DATASET_NOT_FOUND"
|
||||
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO")
|
||||
@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
|
||||
@patch("superset.common.query_context_factory.QueryContextFactory")
|
||||
def test_compile_chart_returns_database_error_when_wrapped_in_query_failed(
|
||||
mock_factory, mock_cmd_cls, mock_dataset_dao
|
||||
):
|
||||
"""ChartDataCommand converts OperationalError to a string inside
|
||||
ChartDataQueryFailedError (no __cause__ set). _classify_as_database_error
|
||||
should use db_engine_spec.extract_errors() to detect the DB error."""
|
||||
from superset.commands.chart.exceptions import ChartDataQueryFailedError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.mcp_service.chart.compile import _compile_chart
|
||||
|
||||
mock_factory.return_value.create.return_value = Mock()
|
||||
mock_cmd_cls.return_value.validate.return_value = None
|
||||
|
||||
# Real scenario: __cause__ is NOT set, error is just a string
|
||||
wrapped = ChartDataQueryFailedError(
|
||||
"Error: (psycopg2.OperationalError) connection to server at '10.0.0.1',"
|
||||
" port 5432 failed: FATAL: tenant not found"
|
||||
)
|
||||
mock_cmd_cls.return_value.run.side_effect = wrapped
|
||||
|
||||
# Mock the dataset's db_engine_spec to return GENERIC_DB_ENGINE_ERROR
|
||||
mock_db = Mock()
|
||||
mock_db.db_engine_spec.extract_errors.return_value = [
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
|
||||
message="connection to server failed",
|
||||
level=ErrorLevel.ERROR,
|
||||
extra={"engine_name": "PostgreSQL"},
|
||||
)
|
||||
]
|
||||
mock_dataset = Mock()
|
||||
mock_dataset.database = mock_db
|
||||
mock_dataset_dao.find_by_id.return_value = mock_dataset
|
||||
|
||||
result = _compile_chart(
|
||||
form_data={
|
||||
"metrics": [{"label": "count", "expressionType": "SIMPLE"}],
|
||||
"adhoc_filters": [],
|
||||
},
|
||||
dataset_id=1,
|
||||
)
|
||||
|
||||
assert not result.success
|
||||
assert "Database connection error" in result.error
|
||||
assert result.error_obj is not None
|
||||
assert result.error_obj.error_type == "database_connection_error"
|
||||
assert result.error_obj.error_code == "DATABASE_CONNECTION_ERROR"
|
||||
mock_db.db_engine_spec.extract_errors.assert_called_once()
|
||||
|
||||
|
||||
@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
|
||||
@patch("superset.common.query_context_factory.QueryContextFactory")
|
||||
def test_compile_chart_returns_database_error_on_raw_sqlalchemy_error(
|
||||
mock_factory, mock_cmd_cls
|
||||
):
|
||||
"""When SQLAlchemyError escapes unwrapped, _compile_chart should
|
||||
catch it and return a database_connection_error."""
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from superset.mcp_service.chart.compile import _compile_chart
|
||||
|
||||
mock_factory.return_value.create.return_value = Mock()
|
||||
mock_cmd_cls.return_value.validate.return_value = None
|
||||
mock_cmd_cls.return_value.run.side_effect = OperationalError(
|
||||
"connection to server at '10.0.0.1', port 5432 failed: Connection timed out",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
result = _compile_chart(
|
||||
form_data={
|
||||
"metrics": [{"label": "count", "expressionType": "SIMPLE"}],
|
||||
"adhoc_filters": [],
|
||||
},
|
||||
dataset_id=1,
|
||||
)
|
||||
|
||||
assert not result.success
|
||||
assert "Database connection error" in result.error
|
||||
assert result.error_obj is not None
|
||||
assert result.error_obj.error_type == "database_connection_error"
|
||||
assert result.error_obj.error_code == "DATABASE_CONNECTION_ERROR"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_factory",
|
||||
[
|
||||
|
||||
@@ -20,6 +20,7 @@ Unit tests for dashboard generation MCP tools
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -117,8 +118,8 @@ def _mock_dashboard(id: int = 1, title: str = "Test Dashboard") -> Mock:
|
||||
dashboard.slug = f"test-dashboard-{id}"
|
||||
dashboard.description = "Test dashboard description"
|
||||
dashboard.published = True
|
||||
dashboard.created_on = "2024-01-01"
|
||||
dashboard.changed_on = "2024-01-01"
|
||||
dashboard.created_on = datetime(2024, 1, 1)
|
||||
dashboard.changed_on = datetime(2024, 1, 1)
|
||||
dashboard.created_by = Mock()
|
||||
dashboard.created_by.username = "test_user"
|
||||
dashboard.changed_by = Mock()
|
||||
|
||||
@@ -1317,3 +1317,153 @@ class TestColumnInfoIsNullable:
|
||||
{"name": "c", "type": "int", "is_nullable": "UNKNOWN"}
|
||||
)
|
||||
assert col.is_nullable is None
|
||||
|
||||
|
||||
class TestDestructiveDDLBlocking:
|
||||
"""Tests for destructive DDL blocking in execute_sql."""
|
||||
|
||||
@pytest.fixture
|
||||
def ddl_mocks(self):
|
||||
"""Common mock wiring for DDL blocking tests."""
|
||||
with (
|
||||
patch("superset.db") as mock_db,
|
||||
patch("superset.security_manager") as mock_sm,
|
||||
):
|
||||
mock_database = _mock_database()
|
||||
mock_database.db_engine_spec.engine = "postgresql"
|
||||
query_chain = mock_db.session.query.return_value
|
||||
query_chain.filter_by.return_value.first.return_value = mock_database
|
||||
mock_sm.can_access_database.return_value = True
|
||||
yield mock_database
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_table_blocked(self, ddl_mocks, mcp_server):
|
||||
"""DROP TABLE is blocked before reaching the executor."""
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{"request": {"database_id": 1, "sql": "DROP TABLE birth_names"}},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is False
|
||||
assert "Destructive DDL" in data["error"]
|
||||
assert "DROP" in data["error"]
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_blocked(self, ddl_mocks, mcp_server):
|
||||
"""TRUNCATE TABLE is blocked before reaching the executor."""
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{"request": {"database_id": 1, "sql": "TRUNCATE TABLE birth_names"}},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is False
|
||||
assert "Destructive DDL" in data["error"]
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alter_table_blocked(self, ddl_mocks, mcp_server):
|
||||
"""ALTER TABLE is blocked before reaching the executor."""
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"sql": "ALTER TABLE birth_names ADD COLUMN x INT",
|
||||
}
|
||||
},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is False
|
||||
assert "Destructive DDL" in data["error"]
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_in_multi_statement_blocked(self, ddl_mocks, mcp_server):
|
||||
"""DROP TABLE hidden in a multi-statement query is blocked."""
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"sql": "DROP TABLE birth_names; SELECT 1",
|
||||
}
|
||||
},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is False
|
||||
assert "Destructive DDL" in data["error"]
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_allowed(self, ddl_mocks, mcp_server):
|
||||
"""SELECT queries pass through the DDL check."""
|
||||
ddl_mocks.execute.return_value = _create_select_result(
|
||||
rows=[{"x": 1}], columns=["x"], original_sql="SELECT 1"
|
||||
)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{"request": {"database_id": 1, "sql": "SELECT 1"}},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is True
|
||||
ddl_mocks.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_allowed(self, ddl_mocks, mcp_server):
|
||||
"""INSERT queries pass through the DDL check (DML is allowed)."""
|
||||
ddl_mocks.execute.return_value = _create_dml_result(
|
||||
affected_rows=1,
|
||||
original_sql="INSERT INTO t VALUES (1)",
|
||||
)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{"request": {"database_id": 1, "sql": "INSERT INTO t VALUES (1)"}},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is True
|
||||
ddl_mocks.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_failure_blocks_query(self, ddl_mocks, mcp_server):
|
||||
"""When SQL parsing fails, the query is blocked (fail-closed)."""
|
||||
import sys
|
||||
|
||||
execute_sql_mod = sys.modules["superset.mcp_service.sql_lab.tool.execute_sql"]
|
||||
with patch.object(
|
||||
execute_sql_mod,
|
||||
"SQLScript",
|
||||
side_effect=Exception("parse error"),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{"request": {"database_id": 1, "sql": "DROP TABLE birth_names"}},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is False
|
||||
assert "could not be parsed" in data["error"]
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_table_blocked_mysql(self, ddl_mocks, mcp_server):
|
||||
"""DROP TABLE is blocked for non-PostgreSQL dialects too."""
|
||||
ddl_mocks.db_engine_spec.engine = "mysql"
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{"request": {"database_id": 1, "sql": "DROP TABLE users"}},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is False
|
||||
assert "Destructive DDL" in data["error"]
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@@ -346,6 +346,42 @@ class TestGenerateBugReportViaMCP:
|
||||
finally:
|
||||
app.config.pop("MCP_BUG_REPORT_CONTACT", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_bug_report_includes_mcp_call_id(self, mcp_server):
|
||||
"""Bug report includes mcp_call_id when provided."""
|
||||
with patch("flask.g") as mock_g:
|
||||
mock_g.user = None
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"generate_bug_report",
|
||||
{
|
||||
"request": {
|
||||
"tool_name": "health_check",
|
||||
"mcp_call_id": "abc123def456",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "abc123def456" in data["report"]
|
||||
assert "MCP Call ID" in data["report"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_bug_report_omits_mcp_call_id_when_absent(self, mcp_server):
|
||||
"""Bug report omits MCP Call ID line when not provided."""
|
||||
with patch("flask.g") as mock_g:
|
||||
mock_g.user = None
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"generate_bug_report",
|
||||
{"request": {"tool_name": "health_check"}},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "MCP Call ID" not in data["report"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema-level tests: max_length caps
|
||||
|
||||
@@ -24,10 +24,14 @@ Tests verify that:
|
||||
- _extract_context_info() extracts entity IDs from params
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from mcp import types as mt
|
||||
|
||||
from superset.mcp_service.middleware import LoggingMiddleware
|
||||
|
||||
@@ -115,8 +119,6 @@ class TestLoggingMiddlewareOnCallTool:
|
||||
sits between GlobalErrorHandler and StructuredContentStripper, it
|
||||
catches the ToolError directly.
|
||||
"""
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(name="get_chart_info")
|
||||
call_next = AsyncMock(side_effect=ToolError("Chart 999999 not found"))
|
||||
@@ -130,6 +132,62 @@ class TestLoggingMiddlewareOnCallTool:
|
||||
assert call_kwargs["curated_payload"]["tool"] == "get_chart_info"
|
||||
assert call_kwargs["duration_ms"] >= 0
|
||||
|
||||
@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_includes_mcp_call_id_in_curated_payload(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
):
|
||||
"""on_call_tool adds mcp_call_id to curated_payload."""
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(name="list_charts")
|
||||
call_next = AsyncMock(return_value="tool_result")
|
||||
|
||||
await middleware.on_call_tool(ctx, call_next)
|
||||
|
||||
call_kwargs = mock_event_logger.log.call_args[1]
|
||||
mcp_call_id = call_kwargs["curated_payload"]["mcp_call_id"]
|
||||
assert isinstance(mcp_call_id, str)
|
||||
assert len(mcp_call_id) == 32
|
||||
|
||||
@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_injects_mcp_call_id_into_tool_result_meta(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
):
|
||||
"""on_call_tool injects mcp_call_id into ToolResult.meta."""
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(name="list_charts")
|
||||
original_result = ToolResult(content=[mt.TextContent(type="text", text="ok")])
|
||||
call_next = AsyncMock(return_value=original_result)
|
||||
|
||||
result = await middleware.on_call_tool(ctx, call_next)
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert "mcp_call_id" in result.meta
|
||||
assert len(result.meta["mcp_call_id"]) == 32
|
||||
|
||||
@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_preserves_existing_meta(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
):
|
||||
"""on_call_tool merges mcp_call_id with existing ToolResult.meta."""
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(name="list_charts")
|
||||
original_result = ToolResult(
|
||||
content=[mt.TextContent(type="text", text="ok")],
|
||||
meta={"existing_key": "existing_value"},
|
||||
)
|
||||
call_next = AsyncMock(return_value=original_result)
|
||||
|
||||
result = await middleware.on_call_tool(ctx, call_next)
|
||||
|
||||
assert result.meta["existing_key"] == "existing_value"
|
||||
assert "mcp_call_id" in result.meta
|
||||
|
||||
@patch("superset.mcp_service.middleware.event_logger")
|
||||
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
|
||||
@pytest.mark.asyncio
|
||||
@@ -241,9 +299,6 @@ class TestIsErrorResponse:
|
||||
def test_detects_error_schema_response(self):
|
||||
"""Detects ToolResult containing a serialized error schema
|
||||
(ChartError, DashboardError, etc.) via "error_type" field."""
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from mcp import types as mt
|
||||
|
||||
middleware = LoggingMiddleware()
|
||||
error_json = (
|
||||
'{"error": "Chart 999 not found",'
|
||||
@@ -255,9 +310,6 @@ class TestIsErrorResponse:
|
||||
|
||||
def test_success_response_not_detected_as_error(self):
|
||||
"""Normal ToolResult is not detected as error."""
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from mcp import types as mt
|
||||
|
||||
middleware = LoggingMiddleware()
|
||||
result = ToolResult(
|
||||
content=[mt.TextContent(type="text", text="Successfully retrieved data")]
|
||||
@@ -266,8 +318,6 @@ class TestIsErrorResponse:
|
||||
|
||||
def test_empty_content_not_detected_as_error(self):
|
||||
"""ToolResult with empty content is not detected as error."""
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
|
||||
middleware = LoggingMiddleware()
|
||||
assert middleware._is_error_response(ToolResult(content=[])) is False
|
||||
|
||||
@@ -279,9 +329,6 @@ class TestIsErrorResponse:
|
||||
):
|
||||
"""on_call_tool logs success=False when tool returns an
|
||||
error schema (e.g. ChartError)."""
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from mcp import types as mt
|
||||
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(name="get_chart_info")
|
||||
|
||||
@@ -297,7 +344,9 @@ class TestIsErrorResponse:
|
||||
|
||||
result = await middleware.on_call_tool(ctx, call_next)
|
||||
|
||||
assert result == error_result
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.content == error_result.content
|
||||
assert "mcp_call_id" in result.meta
|
||||
mock_event_logger.log.assert_called_once()
|
||||
call_kwargs = mock_event_logger.log.call_args[1]
|
||||
assert call_kwargs["curated_payload"]["success"] is False
|
||||
@@ -320,10 +369,6 @@ class TestMiddlewareChainOrder:
|
||||
):
|
||||
"""Tool exception is logged as success=False through the
|
||||
real middleware chain from build_middleware_list()."""
|
||||
from functools import partial
|
||||
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
|
||||
from superset.mcp_service.server import build_middleware_list
|
||||
|
||||
middleware_list = build_middleware_list()
|
||||
@@ -362,3 +407,31 @@ class TestMiddlewareChainOrder:
|
||||
"them. Ensure StructuredContentStripper is outermost "
|
||||
"(first added) in build_middleware_list()."
|
||||
)
|
||||
|
||||
@patch("superset.mcp_service.middleware.event_logger")
|
||||
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_middleware_chain_error_result_has_mcp_call_id(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
):
|
||||
"""When a tool raises, the error ToolResult from
|
||||
StructuredContentStripper still carries mcp_call_id in meta."""
|
||||
from superset.mcp_service.server import build_middleware_list
|
||||
|
||||
middleware_list = build_middleware_list()
|
||||
|
||||
async def failing_tool(context: Any) -> Any:
|
||||
raise ValueError("chart not found")
|
||||
|
||||
chain = failing_tool
|
||||
for mw in reversed(middleware_list):
|
||||
chain = partial(mw, call_next=chain)
|
||||
|
||||
ctx = _make_context(name="get_chart_info")
|
||||
result = await chain(ctx)
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.content[0].text.startswith("Error:")
|
||||
assert result.meta is not None
|
||||
assert "mcp_call_id" in result.meta
|
||||
assert len(result.meta["mcp_call_id"]) == 32
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user