mirror of
https://github.com/apache/superset.git
synced 2026-09-01 04:51:23 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985ebb796e | ||
|
|
b304f33a9b | ||
|
|
83d93b8b42 | ||
|
|
06f421ed4a | ||
|
|
dc671ae44a | ||
|
|
c49d01223c | ||
|
|
69f052985f | ||
|
|
f4cc871f8c | ||
|
|
2f11240339 | ||
|
|
a7a051777c | ||
|
|
b4218136be | ||
|
|
fd8d7009a5 | ||
|
|
beff76cb4d | ||
|
|
d6b31376a5 | ||
|
|
1575b83f96 | ||
|
|
83826839a0 | ||
|
|
a8ab5f973d | ||
|
|
7cdfe3ffe7 | ||
|
|
9a24d42d65 | ||
|
|
3303d4e09e |
+20
@@ -302,6 +302,26 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config
|
||||
|
||||
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
|
||||
|
||||
### Soft delete and restore for charts
|
||||
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
|
||||
|
||||
With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the chart (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from all list, detail, and lookup endpoints. Charts in this state are excluded from default queries and from relationship loads (e.g. `dashboard.slices`).
|
||||
|
||||
**Operational notes:** a report schedule whose target chart is soft-deleted now fails its runs with an explicit error ("The chart this report targets was deleted...") until the chart is restored or the report re-pointed — chart deletion is blocked while a report references the chart, but a validate/commit race or a flag toggle can still produce this state. Dashboards **preserve** their membership rows for soft-deleted charts: saving a dashboard does not sever a trashed member, and restoring the chart re-attaches it to its dashboards.
|
||||
|
||||
**New endpoint** — `POST /api/v1/chart/<uuid>/restore` clears `deleted_at` and returns the chart to active state. Requires `can_write on Chart` and ownership of the row (or admin). Soft-deleted charts can also be surfaced in the list endpoint via the new `chart_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to charts they own — the same audience that can restore them.
|
||||
|
||||
**Permissions migration:** existing role grants of `can_write on Chart` cover the new restore endpoint automatically; no role migration is required.
|
||||
|
||||
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_slices_deleted_at`) to the `slices` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `slices` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking).
|
||||
|
||||
**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted charts silently become live, active charts with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) *before* downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
|
||||
|
||||
**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted chart's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). The operation is permission-gated: non-owners get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
|
||||
- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` still works via the legacy path. Grant your Slack bot the `channels:read` and `groups:read` scopes so existing `Slack` recipients can be auto-upgraded to `SlackV2` on next send. Operators who explicitly override the flag to `False`, or whose Slack bot is missing those scopes, will see deprecation warnings while text-only sends continue through the legacy path.
|
||||
|
||||
### Soft delete and restore for dashboards
|
||||
|
||||
+4
-4
@@ -61,12 +61,12 @@
|
||||
"@storybook/addon-docs": "^10.4.5",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.43",
|
||||
"antd": "^6.4.5",
|
||||
"antd": "^6.5.0",
|
||||
"baseline-browser-mapping": "^2.10.40",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.0",
|
||||
"js-yaml": "^5.1.0",
|
||||
"js-yaml": "^5.2.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -97,10 +97,10 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier": "^3.9.1",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"webpack": "^5.108.0"
|
||||
"webpack": "^5.108.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
+85
-85
@@ -212,18 +212,18 @@
|
||||
resolved "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz"
|
||||
integrity sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==
|
||||
|
||||
"@ant-design/icons-svg@^4.4.2":
|
||||
version "4.4.2"
|
||||
resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz"
|
||||
integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==
|
||||
"@ant-design/icons-svg@^4.5.0":
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz#7b1c567e489840d747f211d3688949bcba363ad2"
|
||||
integrity sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==
|
||||
|
||||
"@ant-design/icons@^6.2.5":
|
||||
version "6.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.5.tgz#31c142aa6ce5eaf99598aaead222f4c459693512"
|
||||
integrity sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==
|
||||
"@ant-design/icons@^6.2.5", "@ant-design/icons@^6.3.1":
|
||||
version "6.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.3.2.tgz#8291dffc53003db9a5df59f80ed758473cd5c8df"
|
||||
integrity sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/icons-svg" "^4.4.2"
|
||||
"@ant-design/icons-svg" "^4.5.0"
|
||||
"@rc-component/util" "^1.11.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
@@ -3433,12 +3433,12 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.24.4"
|
||||
|
||||
"@rc-component/cascader@~1.16.1":
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.16.1.tgz#94193ee55009219999a46e005a0f8589c8c021a2"
|
||||
integrity sha512-wxLopwM+EBed0zNNGdnGE4coYoqcO+XD42fHgn+pDvO+XzhNFbdgSlSNXdKocIYqccvqgWvoxDPNb0OVRdi59A==
|
||||
"@rc-component/cascader@~1.17.0":
|
||||
version "1.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.17.0.tgz#52c0eceada2c7b4b37ebe822c19a6544b9562edf"
|
||||
integrity sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.7.1"
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
@@ -3477,12 +3477,12 @@
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.3.0"
|
||||
|
||||
"@rc-component/dialog@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/dialog/-/dialog-1.9.0.tgz#3134f8fa8644d9bc228c862668b90de048c7ea1a"
|
||||
integrity sha512-zbAAogkg4kkKum79sLE6M+vq1jSAW25zdkafrahgcTP9t9S//SD634Znd1A4c8F2Gc12ZKnehGLsVaaOvZzD2A==
|
||||
"@rc-component/dialog@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/dialog/-/dialog-1.10.0.tgz#06341f175f7bd5b6754e8578cdc53974361772af"
|
||||
integrity sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.1.3"
|
||||
"@rc-component/motion" "^1.3.3"
|
||||
"@rc-component/portal" "^2.1.0"
|
||||
"@rc-component/util" "^1.9.0"
|
||||
clsx "^2.1.1"
|
||||
@@ -3543,21 +3543,21 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.9.0.tgz#1e133d607835854430e264b681b7b32c4b49daa7"
|
||||
integrity sha512-WUwfFKDSOF5S9UPsNsXcLYtzjTxBGsftTXWRbZuxX6BYrsySISTnujfJNgaaQ6qVzaCDJ35QUkZKvsYxip1C5g==
|
||||
"@rc-component/mentions@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.10.0.tgz#46b1117cfb0c716b476e97f342555eccc2f41c97"
|
||||
integrity sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.0"
|
||||
"@rc-component/menu" "~1.3.0"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/menu@~1.3.0", "@rc-component/menu@~1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/menu/-/menu-1.3.1.tgz#16cae71a01080914e8bac08359fccdda7bfce540"
|
||||
integrity sha512-pSZl9nBPgKgxN0aaW7NilIBEwWsc+43S+ulGdWAg9afak96dNOGWsGx0DLLBB1VQsAJvo6bQMTDzXoPlEHsBEw==
|
||||
"@rc-component/menu@~1.4.0", "@rc-component/menu@~1.4.1":
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/menu/-/menu-1.4.1.tgz#aa20b6d6087f5ddd23d4a21b0ccce35b98988c8e"
|
||||
integrity sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
@@ -3606,23 +3606,23 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/pagination@~1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/pagination/-/pagination-1.3.0.tgz#ee66301e37a03974826fb3028a91a1aeacdcd0ac"
|
||||
integrity sha512-12ahTY+HPITg1L2bjWKXUqBJe/oOnpA2QsChdCjthqLVf/e19StiCsv8OLKpWoHbc+8PFEkNjRqRqrLoRBHjFw==
|
||||
"@rc-component/pagination@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/pagination/-/pagination-1.4.0.tgz#e2ea93d09c59e1aca88d849b76536c6435f46fb8"
|
||||
integrity sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/picker@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/picker/-/picker-1.10.0.tgz#6989f0ae67fca8db00e31f81a8217c8bc370cd34"
|
||||
integrity sha512-vVOXP2RVWozwpERGUFAehVH1Jz6o/uRrAb9qSZm1LC+iJs8rvEwFo1bzz2jlOYV+uWwu0dIuG86tnDui14Ea0w==
|
||||
"@rc-component/picker@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/picker/-/picker-1.11.0.tgz#ab85a2f1a3f13a55829837d29c862967e701cddd"
|
||||
integrity sha512-6qXGKtoJvO8sUd17m5cyNEbEJub0zflCHnaZTBBmj63DPRZYc0WEHN8rp6hFSl+yMCJS/dJY5G+1fQ8bLCuD7A==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/trigger" "^3.6.15"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0":
|
||||
@@ -3673,10 +3673,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/select@~1.7.0", "@rc-component/select@~1.7.1":
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.7.1.tgz#cdda0ac185f00ebed1c85e7809ae1f7855a9f7ab"
|
||||
integrity sha512-GZ1cMJk2xQh0VHyOQjjG8drYL4iu24NcbkXioUcReQOCUr+ub/3fmRonZe6cRPEZhWMbJdeHsqnEltogDaZ5Tg==
|
||||
"@rc-component/select@~1.8.0", "@rc-component/select@~1.8.2":
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.8.2.tgz#f016992dae5c57186535512d73783e2fc7e4c59e"
|
||||
integrity sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
@@ -3684,10 +3684,10 @@
|
||||
"@rc-component/virtual-list" "^1.2.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/slider@~1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz"
|
||||
integrity sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==
|
||||
"@rc-component/slider@~1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/slider/-/slider-1.1.1.tgz#50ea53df427d94dea8000edbe99919f6bd5fed51"
|
||||
integrity sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
@@ -3719,13 +3719,13 @@
|
||||
"@rc-component/virtual-list" "^1.0.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tabs@~1.9.1":
|
||||
version "1.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.9.1.tgz#52b9cb0392c718fba43e7d558f46f6c910d19acb"
|
||||
integrity sha512-6mY08Fce6aNOHuGsxbzT+f2ekgL9mg1cGGHkittMlVGymjGg+kGupu5v90sRxcUd/paRU9jclLLXtF/PkK1FUA==
|
||||
"@rc-component/tabs@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.11.0.tgz#c157b2fadcdc2f3ab6c69d0098f73e03c6aa0c12"
|
||||
integrity sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==
|
||||
dependencies:
|
||||
"@rc-component/dropdown" "~1.0.0"
|
||||
"@rc-component/menu" "~1.3.0"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
"@rc-component/motion" "^1.1.3"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
@@ -3750,17 +3750,17 @@
|
||||
"@rc-component/util" "^1.7.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree-select@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.10.0.tgz#72e337fd58591f677404189cb3e9d3f718cd3332"
|
||||
integrity sha512-E1U4pn2LAbXEhLJdzIzid7WYbIuFbkTIctuFoeC6weppf8UbPR3+YYB6/ay0c0ksand4gXMRQpa1Z60Auo7VJA==
|
||||
"@rc-component/tree-select@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.11.0.tgz#9080cdf1d28f2ddd6d8a4879b7aa90d3170f7db9"
|
||||
integrity sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.7.0"
|
||||
"@rc-component/tree" "~1.3.0"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree@~1.3.0", "@rc-component/tree@~1.3.2":
|
||||
"@rc-component/tree@~1.3.2":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.3.2.tgz#4b0c13564314eff61ca948c18ef923b87c9d7e44"
|
||||
integrity sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==
|
||||
@@ -6187,51 +6187,51 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.4.5:
|
||||
version "6.4.5"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.4.5.tgz#98372c96af3e562aeff126289ead5e7e5c5f4212"
|
||||
integrity sha512-xyAgX/sqF/CRS1G95oM4ql0+3TBG+tE58aRJqdUPVv4yMZcQrnnkA4cU7Uc5Rny2yK2TrusDVargHzzXUrlJ1g==
|
||||
antd@^6.5.0:
|
||||
version "6.5.0"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.0.tgz#29d3de40f354ab965e201f79c2d0e7658cecc655"
|
||||
integrity sha512-9zbVc9UukfGuqCvIAov01nlpDQWfARNmZQyt21ZhqLX7ilXmi4cdkp12xA48WEmXRXwZvno8A03qQuGE9JG8fg==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
"@ant-design/cssinjs-utils" "^2.1.2"
|
||||
"@ant-design/fast-color" "^3.0.1"
|
||||
"@ant-design/icons" "^6.2.5"
|
||||
"@ant-design/icons" "^6.3.1"
|
||||
"@ant-design/react-slick" "~2.0.0"
|
||||
"@babel/runtime" "^7.29.2"
|
||||
"@rc-component/cascader" "~1.16.1"
|
||||
"@rc-component/cascader" "~1.17.0"
|
||||
"@rc-component/checkbox" "~2.0.0"
|
||||
"@rc-component/collapse" "~1.2.0"
|
||||
"@rc-component/color-picker" "~3.1.1"
|
||||
"@rc-component/dialog" "~1.9.0"
|
||||
"@rc-component/dialog" "~1.10.0"
|
||||
"@rc-component/drawer" "~1.4.2"
|
||||
"@rc-component/dropdown" "~1.0.2"
|
||||
"@rc-component/form" "~1.8.5"
|
||||
"@rc-component/image" "~1.9.0"
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/input-number" "~1.6.2"
|
||||
"@rc-component/mentions" "~1.9.0"
|
||||
"@rc-component/menu" "~1.3.1"
|
||||
"@rc-component/mentions" "~1.10.0"
|
||||
"@rc-component/menu" "~1.4.1"
|
||||
"@rc-component/motion" "^1.3.3"
|
||||
"@rc-component/mutate-observer" "^2.0.1"
|
||||
"@rc-component/notification" "~2.0.7"
|
||||
"@rc-component/pagination" "~1.3.0"
|
||||
"@rc-component/picker" "~1.10.0"
|
||||
"@rc-component/pagination" "~1.4.0"
|
||||
"@rc-component/picker" "~1.11.0"
|
||||
"@rc-component/progress" "~1.0.2"
|
||||
"@rc-component/qrcode" "~2.0.0"
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.2"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.7.1"
|
||||
"@rc-component/slider" "~1.0.1"
|
||||
"@rc-component/select" "~1.8.2"
|
||||
"@rc-component/slider" "~1.1.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.10.2"
|
||||
"@rc-component/tabs" "~1.9.1"
|
||||
"@rc-component/tabs" "~1.11.0"
|
||||
"@rc-component/tooltip" "~1.4.0"
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/tree-select" "~1.10.0"
|
||||
"@rc-component/tree-select" "~1.11.0"
|
||||
"@rc-component/trigger" "^3.9.1"
|
||||
"@rc-component/upload" "~1.1.1"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
@@ -10321,10 +10321,10 @@ js-yaml@^3.13.1:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
js-yaml@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.1.0.tgz#c084ac880197833810a69e9c7e51eae12ff35448"
|
||||
integrity sha512-s8VA5jkR8f22S3NAXmhKPFqGUduqZGlsufabVOgN14iTdw/RXcym7bKkbwjxLK9Yw2lEvvmJjFp119+KPeo8Kg==
|
||||
js-yaml@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.0.tgz#b559a892cae3e32fc5afecc9f18e1672378ddb68"
|
||||
integrity sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
@@ -13208,10 +13208,10 @@ prettier-linter-helpers@^1.0.1:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.8.4:
|
||||
version "3.8.4"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.4.tgz#f334f013ac04a96676f24dabc23c1c4ae1bae411"
|
||||
integrity sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==
|
||||
prettier@^3.9.1:
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.1.tgz#5868bcf16456bfe145ddbfa107c623b6f39e332c"
|
||||
integrity sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==
|
||||
|
||||
pretty-error@^4.0.0:
|
||||
version "4.0.0"
|
||||
@@ -16073,10 +16073,10 @@ webpack-virtual-modules@^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.108.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.108.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.108.0.tgz#f8bb9b554f25374e8dfa3e1b137b0791a56d7614"
|
||||
integrity sha512-Ln1JuYGPRTXcHECapSFSvACtHmWEN5sQqFJeLLGQ0057S7qzT2eXUz0MZUedtmIrNy3nJgnITSubIYKGED9jSQ==
|
||||
webpack@^5.108.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.108.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.108.2.tgz#3b76d7fec173be8673ff45f6dcaf0442574ee286"
|
||||
integrity sha512-sUWBWPJwWH+QHUObS4lfNaQ368Tj8NaHDBsRJcU/NmQpeOqxV5iQUT2c5nvDWi8WYR5ynF7az+PuMdc+oDLJOA==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
Generated
+60
-394
@@ -109,7 +109,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
@@ -118,10 +118,10 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"ol": "^10.9.0",
|
||||
"query-string": "9.4.0",
|
||||
"query-string": "9.4.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.10.5",
|
||||
"react-arborist": "^3.12.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -215,7 +215,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
@@ -227,7 +227,7 @@
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
@@ -263,7 +263,7 @@
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.4",
|
||||
"prettier": "3.9.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-dnd-test-backend": "^16.0.1",
|
||||
@@ -12122,17 +12122,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
|
||||
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
|
||||
"integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/type-utils": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"@typescript-eslint/scope-manager": "8.62.0",
|
||||
"@typescript-eslint/type-utils": "8.62.0",
|
||||
"@typescript-eslint/utils": "8.62.0",
|
||||
"@typescript-eslint/visitor-keys": "8.62.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -12145,7 +12145,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.61.1",
|
||||
"@typescript-eslint/parser": "^8.62.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -12185,69 +12185,6 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
|
||||
"integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/visitor-keys": "8.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
|
||||
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
|
||||
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
|
||||
@@ -12270,29 +12207,15 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
|
||||
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
|
||||
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
|
||||
"integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1"
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/visitor-keys": "8.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -12320,15 +12243,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
|
||||
"integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1",
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/typescript-estree": "8.62.0",
|
||||
"@typescript-eslint/utils": "8.62.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -12344,116 +12267,10 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
|
||||
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.1",
|
||||
"@typescript-eslint/types": "^8.61.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
|
||||
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.61.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
|
||||
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
|
||||
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -12492,38 +12309,6 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
|
||||
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
|
||||
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -12547,19 +12332,6 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -12577,16 +12349,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
|
||||
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
|
||||
"integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1"
|
||||
"@typescript-eslint/scope-manager": "8.62.0",
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/typescript-estree": "8.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -12600,120 +12372,14 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
|
||||
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.1",
|
||||
"@typescript-eslint/types": "^8.61.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
|
||||
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.61.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
|
||||
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
|
||||
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -19043,9 +18709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz",
|
||||
"integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
|
||||
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -33834,9 +33500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
|
||||
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.3.tgz",
|
||||
"integrity": "sha512-HWmu+K+zvHNpaMfSnYeqdqrDbR16cuIXaPx8WoHaviQkDJh1/0BNtOZmHVQI5jc3wXv0H1yXc9wjvFdXh+n3hQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -34201,9 +33867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.0.tgz",
|
||||
"integrity": "sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA==",
|
||||
"version": "9.4.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.1.tgz",
|
||||
"integrity": "sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.4.1",
|
||||
@@ -35034,9 +34700,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-arborist": {
|
||||
"version": "3.10.5",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.10.5.tgz",
|
||||
"integrity": "sha512-gbxFTLb0vCGmFOcJ/ZY2/ymCIdG4U8ok6fApsSeUWhGxUXwgtLpbylKNbSpzhjovv1D3VRFTW3OiiRs+Coh1vg==",
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.12.0.tgz",
|
||||
"integrity": "sha512-QVSwe3/W3rzdzX9ssaT95OnyddBjejyJZAJtNT3W4dJH+26R1bdcxdoYMbqq9upsYsU1HtSZ0/z9d8yOMRstvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-dnd": "^14.0.3",
|
||||
@@ -43610,7 +43276,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.29.7",
|
||||
@@ -43654,7 +43320,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"tinycolor2": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -44159,7 +43825,7 @@
|
||||
"dompurify": "^3.4.11",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
@@ -44182,7 +43848,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
@@ -44222,7 +43888,7 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"geojson": "^0.5.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
@@ -44252,7 +43918,7 @@
|
||||
"acorn": "^8.17.0",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -44297,7 +43963,7 @@
|
||||
"currencyformatter.js": "^1.0.5",
|
||||
"handlebars-group-by": "^1.0.1",
|
||||
"just-handlebars-helpers": "^1.0.19",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
@@ -44378,7 +44044,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
@@ -44419,7 +44085,7 @@
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"d3-cloud": "^1.2.9",
|
||||
"d3-scale": "^4.0.2",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-cloud": "^1.2.9"
|
||||
@@ -44468,7 +44134,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"ngeohash": "^0.6.3",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
@@ -201,10 +201,10 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"ol": "^10.9.0",
|
||||
"query-string": "9.4.0",
|
||||
"query-string": "9.4.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.10.5",
|
||||
"react-arborist": "^3.12.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -298,7 +298,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
@@ -310,7 +310,7 @@
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
@@ -346,7 +346,7 @@
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.4",
|
||||
"prettier": "3.9.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-dnd-test-backend": "^16.0.1",
|
||||
|
||||
@@ -122,6 +122,6 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,3 +173,82 @@ test('resetTranslation does nothing when not yet configured', () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// --- autoConfigureFromWindow ----------------------------------------------
|
||||
// These cover the bootstrap-injection path used to dodge the
|
||||
// module-level `const X = t(...)` race across code-split chunks
|
||||
// (upstream issue #35330).
|
||||
|
||||
test('t() self-configures from window.__SUPERSET_LANGUAGE_PACK__ on first call', () => {
|
||||
jest.isolateModules(() => {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'fr',
|
||||
plural_forms: 'nplurals=2; plural=(n > 1);',
|
||||
},
|
||||
hello: ['bonjour'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
expect(t('hello')).toBe('bonjour');
|
||||
// No "should call configure" warning because we self-configured first.
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
});
|
||||
});
|
||||
|
||||
test('t() falls back to msgid when window has no language pack', () => {
|
||||
jest.isolateModules(() => {
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
expect(t('hello')).toBe('hello');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/was called before configure\(\)/),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test('explicit configure() takes precedence over window pack', () => {
|
||||
jest.isolateModules(() => {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'fr',
|
||||
plural_forms: 'nplurals=2; plural=(n > 1);',
|
||||
},
|
||||
hello: ['bonjour'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { configure, t } = require('./TranslatorSingleton');
|
||||
configure({
|
||||
languagePack: {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'es',
|
||||
plural_forms: 'nplurals=2; plural=(n != 1);',
|
||||
},
|
||||
hello: ['hola'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(t('hello')).toBe('hola');
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,31 @@ function configure(config?: TranslatorConfig) {
|
||||
return singleton;
|
||||
}
|
||||
|
||||
// When webpack splits @apache-superset/core across chunks, each
|
||||
// chunk-local copy of this module has its own `singleton` and
|
||||
// `isConfigured` state. The first `t()` call in a late chunk hits a
|
||||
// fresh, unconfigured Translator and returns the English msgid. To
|
||||
// make translations survive chunk duplication, the HTML template
|
||||
// stashes the language pack on window and we self-configure from it
|
||||
// on first access. See upstream issue #35330.
|
||||
declare global {
|
||||
interface Window {
|
||||
__SUPERSET_LANGUAGE_PACK__?: TranslatorConfig['languagePack'];
|
||||
}
|
||||
}
|
||||
|
||||
function autoConfigureFromWindow() {
|
||||
if (isConfigured) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
const pack = window.__SUPERSET_LANGUAGE_PACK__;
|
||||
if (pack) {
|
||||
configure({ languagePack: pack });
|
||||
}
|
||||
}
|
||||
|
||||
function getInstance() {
|
||||
autoConfigureFromWindow();
|
||||
|
||||
if (typeof singleton === 'undefined') {
|
||||
singleton = new Translator();
|
||||
}
|
||||
@@ -85,11 +109,16 @@ function addLocaleData(data: LocaleData) {
|
||||
}
|
||||
|
||||
function t(input: string, ...args: unknown[]) {
|
||||
// Self-configure from the bootstrap-injected window pack before deciding
|
||||
// whether to warn, so a chunk-local copy that hasn't seen configure() yet
|
||||
// doesn't warn (or fall back to English) when the pack is available.
|
||||
autoConfigureFromWindow();
|
||||
if (!isConfigured) warnPreConfigure('t', input);
|
||||
return getInstance().translate(input, ...args);
|
||||
}
|
||||
|
||||
function tn(key: string, ...args: unknown[]) {
|
||||
autoConfigureFromWindow();
|
||||
if (!isConfigured) warnPreConfigure('tn', key);
|
||||
return getInstance().translateWithNumber(key, ...args);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"tinycolor2": "*",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"dompurify": "^3.4.11",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
onChartStateChange,
|
||||
chartState,
|
||||
metricSqlExpressions,
|
||||
rawSummaryColumns,
|
||||
showNumberedColumn,
|
||||
} = props;
|
||||
|
||||
@@ -113,26 +114,58 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
// A single effect owns every ownState write derived from render state.
|
||||
// updateTableOwnState replaces ownState wholesale, so separate effects that
|
||||
// each spread serverPaginationData in the same render would clobber one
|
||||
// another's keys: clamping the current page, priming the raw-mode summary
|
||||
// columns and nudging a re-query for missing totals must be one combined
|
||||
// delta.
|
||||
useEffect(() => {
|
||||
if (!serverPagination || !serverPaginationData || !rowCount) return;
|
||||
const nextOwnState = { ...serverPaginationData };
|
||||
let changed = false;
|
||||
|
||||
const currentPage = serverPaginationData.currentPage ?? 0;
|
||||
const currentPageSize = serverPaginationData.pageSize ?? serverPageLength;
|
||||
const totalPages = Math.ceil(rowCount / currentPageSize);
|
||||
if (serverPagination && serverPaginationData && rowCount !== undefined) {
|
||||
const currentPage = serverPaginationData.currentPage ?? 0;
|
||||
const currentPageSize = serverPaginationData.pageSize ?? serverPageLength;
|
||||
const totalPages = Math.ceil(rowCount / currentPageSize);
|
||||
// An empty result set clamps to page zero; a shrunken one clamps to its
|
||||
// last remaining page.
|
||||
const clampedPage = Math.max(0, Math.min(currentPage, totalPages - 1));
|
||||
if (clampedPage !== currentPage) {
|
||||
nextOwnState.currentPage = clampedPage;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage >= totalPages && totalPages > 0) {
|
||||
const validPage = Math.max(0, totalPages - 1);
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
currentPage: validPage,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
const primed = (serverPaginationData?.rawSummaryColumns ?? []) as string[];
|
||||
const requested = Boolean(serverPaginationData?.totalsRequested);
|
||||
if (isRawRecords && showTotals && !isEqual(primed, rawSummaryColumns)) {
|
||||
nextOwnState.rawSummaryColumns = rawSummaryColumns;
|
||||
changed = true;
|
||||
}
|
||||
// A renderTrigger toggle re-renders without re-querying; requesting totals
|
||||
// through ownState dispatches the standard re-query whose buildQuery
|
||||
// carries the totals query for the active mode.
|
||||
if (showTotals && totals === undefined && !requested) {
|
||||
nextOwnState.totalsRequested = true;
|
||||
changed = true;
|
||||
} else if (!showTotals && requested) {
|
||||
nextOwnState.totalsRequested = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
updateTableOwnState(setDataMask, nextOwnState);
|
||||
}
|
||||
}, [
|
||||
rowCount,
|
||||
serverPagination,
|
||||
serverPaginationData,
|
||||
rowCount,
|
||||
serverPageLength,
|
||||
isRawRecords,
|
||||
showTotals,
|
||||
totals,
|
||||
rawSummaryColumns,
|
||||
serverPaginationData,
|
||||
setDataMask,
|
||||
]);
|
||||
|
||||
@@ -447,7 +480,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
isUsingTimeComparison ? renderTimeComparisonVisibility : () => null
|
||||
}
|
||||
cleanedTotals={totals || {}}
|
||||
showTotals={showTotals}
|
||||
showTotals={
|
||||
showTotals && totals !== undefined && Object.keys(totals).length > 0
|
||||
}
|
||||
width={width}
|
||||
onColumnStateChange={handleColumnStateChange}
|
||||
chartState={chartState}
|
||||
|
||||
@@ -624,11 +624,28 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
|
||||
// Create totals query AFTER all filters (including AG Grid filters) are applied
|
||||
// This ensures we can properly exclude AG Grid WHERE filters from the totals
|
||||
if (
|
||||
// In raw records mode the summary is a SUM over the numeric columns primed
|
||||
// into ownState by the chart (see rawSummaryColumns in transformProps).
|
||||
// Own state can outlive a datasource or column-selection change, so bound
|
||||
// the primed summary columns to the current raw selection: a stale name
|
||||
// must never reach a SUM metric or the whole chart query fails before the
|
||||
// chart can re-prime its own state.
|
||||
const selectedRawColumns = new Set(
|
||||
ensureIsArray(formData.all_columns).map(getColumnLabel),
|
||||
);
|
||||
const rawSummaryColumns =
|
||||
queryMode === QueryMode.Raw && formData.show_totals
|
||||
? ensureIsArray(
|
||||
ownState.rawSummaryColumns as string[] | undefined,
|
||||
).filter(columnName => selectedRawColumns.has(columnName))
|
||||
: [];
|
||||
const showAggregateTotals = Boolean(
|
||||
metrics?.length &&
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate
|
||||
) {
|
||||
queryMode === QueryMode.Aggregate,
|
||||
);
|
||||
|
||||
if (showAggregateTotals || rawSummaryColumns.length > 0) {
|
||||
// Create a copy of extras without the AG Grid WHERE clause
|
||||
// AG Grid filters in extras.where can reference calculated columns
|
||||
// which aren't available in the totals subquery
|
||||
@@ -661,6 +678,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
extraQueries.push({
|
||||
...queryObject,
|
||||
columns: [],
|
||||
...(rawSummaryColumns.length > 0 && {
|
||||
metrics: rawSummaryColumns.map(columnName => ({
|
||||
expressionType: 'SIMPLE' as const,
|
||||
aggregate: 'SUM' as const,
|
||||
column: { column_name: columnName },
|
||||
label: columnName,
|
||||
})),
|
||||
}),
|
||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
|
||||
@@ -425,21 +425,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'show_totals',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show summary'),
|
||||
default: false,
|
||||
description: t(
|
||||
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
|
||||
),
|
||||
visibility: isAggMode,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -496,6 +481,20 @@ const config: ControlPanelConfig = {
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_totals',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show summary'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Show a summary row of total aggregations: the selected metrics in aggregate mode, or the sum of numeric columns in raw records mode. Note that row limit does not apply to the result.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'show_numbered_column',
|
||||
|
||||
@@ -714,12 +714,11 @@ const transformProps = (
|
||||
|
||||
const hasPageLength = isPositiveNumber(pageLength);
|
||||
|
||||
const totals =
|
||||
showTotals && queryMode === QueryMode.Aggregate
|
||||
? isUsingTimeComparison
|
||||
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
|
||||
: totalQuery?.data[0]
|
||||
: undefined;
|
||||
const totals = showTotals
|
||||
? isUsingTimeComparison
|
||||
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
|
||||
: totalQuery?.data[0]
|
||||
: undefined;
|
||||
|
||||
// Map saved metric/calculated column labels to their SQL expressions for filter resolution
|
||||
const metricSqlExpressions: Record<string, string> = {};
|
||||
@@ -737,6 +736,21 @@ const transformProps = (
|
||||
}
|
||||
});
|
||||
|
||||
// Numeric raw-records columns eligible for the summary row. Only columns
|
||||
// backed by a dataset (physical or calculated) column can be summed
|
||||
// server-side; free-form SQL expression columns are excluded.
|
||||
const datasetColumnNames = new Set(
|
||||
chartProps.datasource.columns
|
||||
.map(col => col.column_name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
const rawSummaryColumns =
|
||||
queryMode === QueryMode.Raw && showTotals
|
||||
? columns
|
||||
.filter(col => col.isNumeric && datasetColumnNames.has(col.key))
|
||||
.map(col => col.key)
|
||||
: [];
|
||||
|
||||
// Strip saved filter from chartState after initial application to prevent re-injection
|
||||
let chartState = serverPaginationData?.chartState as
|
||||
| AgGridChartState
|
||||
@@ -784,6 +798,7 @@ const transformProps = (
|
||||
basicColorFormatters,
|
||||
formData,
|
||||
metricSqlExpressions,
|
||||
rawSummaryColumns,
|
||||
chartState,
|
||||
onChartStateChange,
|
||||
showNumberedColumn,
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface AgGridTableChartTransformedProps<
|
||||
basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[];
|
||||
formData: TableChartFormData;
|
||||
metricSqlExpressions: Record<string, string>;
|
||||
rawSummaryColumns: string[];
|
||||
onChartStateChange?: (chartState: JsonObject) => void;
|
||||
chartState?: AgGridChartState;
|
||||
showNumberedColumn: boolean;
|
||||
|
||||
@@ -27,6 +27,8 @@ interface TableOwnState {
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
searchText?: string;
|
||||
sortBy?: SortByItem[];
|
||||
rawSummaryColumns?: string[];
|
||||
totalsRequested?: boolean;
|
||||
}
|
||||
|
||||
export const updateTableOwnState = (
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import { QueryMode, TimeGranularity, SMART_DATE_ID } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import AgGridTableChart from '../src/AgGridTableChart';
|
||||
import transformProps from '../src/transformProps';
|
||||
@@ -326,6 +327,61 @@ test('AgGridTableChart handles raw records mode', async () => {
|
||||
expect(headerCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const rawSummaryProps = {
|
||||
...testData.basic,
|
||||
rawFormData: {
|
||||
...testData.basic.rawFormData,
|
||||
query_mode: QueryMode.Raw,
|
||||
show_totals: true,
|
||||
},
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columns: [{ column_name: 'name' }, { column_name: 'sum__num' }],
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
...testData.basic.queriesData[0],
|
||||
colnames: [...testData.basic.queriesData[0].colnames, 'num_free'],
|
||||
coltypes: [
|
||||
...testData.basic.queriesData[0].coltypes,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
data: testData.basic.queriesData[0].data,
|
||||
},
|
||||
{ ...testData.basic.queriesData[0], data: [{ sum__num: 12345 }] },
|
||||
],
|
||||
};
|
||||
|
||||
test('transformProps derives numeric dataset columns as rawSummaryColumns in raw mode', () => {
|
||||
const transformed = transformProps(rawSummaryProps);
|
||||
// 'sum__num' is the only numeric column of the selection that is both
|
||||
// numeric and backed by a dataset column: 'num_free' is Numeric but not
|
||||
// dataset-backed, and 'name' is dataset-backed but not numeric.
|
||||
expect(transformed.rawSummaryColumns).toEqual(['sum__num']);
|
||||
});
|
||||
|
||||
test('transformProps surfaces raw records totals when the summary is on', () => {
|
||||
const transformed = transformProps(rawSummaryProps);
|
||||
expect(transformed.totals).toEqual({ sum__num: 12345 });
|
||||
});
|
||||
|
||||
test('transformProps leaves totals undefined in raw mode when the summary is off', () => {
|
||||
const transformed = transformProps({
|
||||
...rawSummaryProps,
|
||||
rawFormData: {
|
||||
...rawSummaryProps.rawFormData,
|
||||
show_totals: false,
|
||||
},
|
||||
});
|
||||
expect(transformed.totals).toBeUndefined();
|
||||
expect(transformed.rawSummaryColumns).toEqual([]);
|
||||
});
|
||||
|
||||
test('transformProps returns empty rawSummaryColumns in aggregate mode', () => {
|
||||
const transformed = transformProps(testData.basic);
|
||||
expect(transformed.rawSummaryColumns).toEqual([]);
|
||||
});
|
||||
|
||||
test('AgGridTableChart corrects invalid page number when currentPage >= totalPages', async () => {
|
||||
const props = transformProps({
|
||||
...testData.basic,
|
||||
@@ -357,3 +413,316 @@ test('AgGridTableChart corrects invalid page number when currentPage >= totalPag
|
||||
expect(mockSetDataMask).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart primes raw summary columns into own state', async () => {
|
||||
const props = transformProps(rawSummaryProps);
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart does not prime summary columns when the summary is off', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
rawFormData: {
|
||||
...rawSummaryProps.rawFormData,
|
||||
show_totals: false,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.ag-container')).toBeInTheDocument();
|
||||
});
|
||||
const primedCalls = mockSetDataMask.mock.calls.filter(
|
||||
([arg]) => arg?.ownState?.rawSummaryColumns,
|
||||
);
|
||||
expect(primedCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('AgGridTableChart clears a stale summary prime when no numeric columns remain', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columns: [{ column_name: 'name' }],
|
||||
},
|
||||
});
|
||||
props.serverPaginationData = { rawSummaryColumns: ['sum__num'] };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({ rawSummaryColumns: [] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart requests aggregate totals when the summary toggles on without data', async () => {
|
||||
const props = transformProps({
|
||||
...testData.basic,
|
||||
rawFormData: {
|
||||
...testData.basic.rawFormData,
|
||||
show_totals: true,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({ totalsRequested: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart clears the aggregate totals request when the summary is off', async () => {
|
||||
const props = transformProps(testData.basic);
|
||||
props.serverPaginationData = { totalsRequested: true };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
totalsRequested: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart clamps the page to zero when the result set is empty', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
props.serverPagination = true;
|
||||
props.rowCount = 0;
|
||||
props.serverPaginationData = { currentPage: 5, pageSize: 20 };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Zero rows means zero pages; a stale page index must reset so row_offset
|
||||
// does not keep pointing past the (now empty) result set. Asserting the
|
||||
// totals keys alongside pins the write to the unified effect rather than
|
||||
// the pagination footer's own page reset.
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
currentPage: 0,
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
totalsRequested: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart merges the page clamp and totals request into one own-state write', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
props.serverPagination = true;
|
||||
props.rowCount = 41;
|
||||
props.serverPaginationData = { currentPage: 5, pageSize: 20 };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// 41 rows at page size 20 leave 3 pages, so page 5 must clamp to 2 in the
|
||||
// same write that primes the summary columns and requests totals; separate
|
||||
// writes would overwrite each other's keys.
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
currentPage: 2,
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
totalsRequested: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart re-requests raw totals when toggled back on with a matching prime', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
serverPaginationData={{ rawSummaryColumns: ['sum__num'] }}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
totalsRequested: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart pins no summary row when totals come back empty', async () => {
|
||||
const props = transformProps({
|
||||
...testData.basic,
|
||||
rawFormData: {
|
||||
...testData.basic.rawFormData,
|
||||
show_totals: true,
|
||||
},
|
||||
});
|
||||
props.showTotals = true;
|
||||
// An empty totals object (e.g. a time-comparison totals result with no
|
||||
// rows) carries nothing to display and must not pin a blank row.
|
||||
props.totals = {};
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.ag-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row');
|
||||
expect(pinnedRows.length).toBe(0);
|
||||
});
|
||||
|
||||
test('AgGridTableChart pins no summary row when totals are absent', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columns: [{ column_name: 'name' }],
|
||||
},
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.ag-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row');
|
||||
expect(pinnedRows.length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1432,4 +1432,133 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
expect(query.extras?.where || undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuery - raw records summary totals', () => {
|
||||
const rawFormData: TableChartFormData = {
|
||||
viz_type: VizType.Table,
|
||||
datasource: '11__table',
|
||||
query_mode: QueryMode.Raw,
|
||||
all_columns: ['name', 'num'],
|
||||
show_totals: true,
|
||||
};
|
||||
|
||||
test('drops summary columns missing from the raw selection', () => {
|
||||
// Own state can outlive a datasource or column-selection change; a
|
||||
// persisted name absent from all_columns must never reach a SUM metric.
|
||||
const { queries } = buildQuery(rawFormData, {
|
||||
ownState: { rawSummaryColumns: ['num', 'ghost_col'] },
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'num' },
|
||||
label: 'num',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps calculated dataset columns in the totals metrics', () => {
|
||||
// Calculated columns are dataset-backed and resolve server-side from
|
||||
// the column name alone (standard SIMPLE adhoc-metric resolution); the
|
||||
// selection intersection must not assume physical columns only.
|
||||
const { queries } = buildQuery(
|
||||
{ ...rawFormData, all_columns: ['name', 'num', 'boys_ratio_calc'] },
|
||||
{ ownState: { rawSummaryColumns: ['num', 'boys_ratio_calc'] } },
|
||||
);
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'num' },
|
||||
label: 'num',
|
||||
},
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'boys_ratio_calc' },
|
||||
label: 'boys_ratio_calc',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('adds no totals query when every primed column left the selection', () => {
|
||||
const { queries } = buildQuery(rawFormData, {
|
||||
ownState: { rawSummaryColumns: ['ghost_col'] },
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('adds a SUM totals query when summary columns are primed', () => {
|
||||
const { queries } = buildQuery(rawFormData, {
|
||||
ownState: { rawSummaryColumns: ['num'] },
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
columns: [],
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
metrics: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'num' },
|
||||
label: 'num',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(queries[1].orderby).toBeUndefined();
|
||||
});
|
||||
|
||||
test('adds no totals query without primed summary columns', () => {
|
||||
const { queries } = buildQuery(rawFormData, { ownState: {} });
|
||||
expect(queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('adds no totals query when show_totals is off', () => {
|
||||
const { queries } = buildQuery(
|
||||
{ ...rawFormData, show_totals: false },
|
||||
{ ownState: { rawSummaryColumns: ['num'] } },
|
||||
);
|
||||
expect(queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps the totals query last with server pagination', () => {
|
||||
const { queries } = buildQuery(
|
||||
{ ...rawFormData, server_pagination: true },
|
||||
{ ownState: { rawSummaryColumns: ['num'] } },
|
||||
);
|
||||
|
||||
expect(queries).toHaveLength(3);
|
||||
expect(queries[1].is_rowcount).toBe(true);
|
||||
expect(queries[2].columns).toEqual([]);
|
||||
expect(queries[2].row_limit).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps aggregate-mode totals metrics untouched', () => {
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
viz_type: VizType.Table,
|
||||
datasource: '11__table',
|
||||
query_mode: QueryMode.Aggregate,
|
||||
groupby: ['state'],
|
||||
metrics: ['count'],
|
||||
show_totals: true,
|
||||
},
|
||||
{ ownState: {} },
|
||||
);
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].columns).toEqual([]);
|
||||
expect(queries[1].metrics).toEqual(['count']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ControlPanelsContainerProps,
|
||||
ControlState,
|
||||
CustomControlItem,
|
||||
isCustomControlItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import config from '../src/controlPanel';
|
||||
|
||||
@@ -75,3 +76,38 @@ test('time_grain_sqla visibility should be case-insensitive', () => {
|
||||
expect(vis(mkProps(['ORDERDATE']), controlState)).toBe(true);
|
||||
expect(vis(mkProps(['some_other_col']), controlState)).toBe(false);
|
||||
});
|
||||
|
||||
test('show_totals renders in the customize tab atop visual formatting', () => {
|
||||
const visualFormatting = config.controlPanelSections.find(
|
||||
section => section?.label === 'Visual formatting',
|
||||
);
|
||||
expect(visualFormatting).toBeDefined();
|
||||
|
||||
const [firstRow] = visualFormatting!.controlSetRows;
|
||||
const firstControl = firstRow[0] as CustomControlItem;
|
||||
expect(firstControl.name).toBe('show_totals');
|
||||
// renderTrigger keeps the whole section classified into the customize tab
|
||||
// and must not regress; without it the section moves to the data tab.
|
||||
expect(firstControl.config.renderTrigger).toBe(true);
|
||||
// No visibility gate: the summary checkbox must render in raw records
|
||||
// mode as well as aggregate mode.
|
||||
expect(firstControl.config.visibility).toBeUndefined();
|
||||
});
|
||||
|
||||
test('every Visual formatting control is a renderTrigger', () => {
|
||||
// A non-renderTrigger control in this section would silently drag the
|
||||
// whole section's classification from the customize tab to the data tab.
|
||||
const visualFormatting = config.controlPanelSections.find(
|
||||
section => section?.label === 'Visual formatting',
|
||||
);
|
||||
expect(visualFormatting).toBeDefined();
|
||||
|
||||
const controls = visualFormatting!.controlSetRows
|
||||
.flat()
|
||||
.filter(isCustomControlItem);
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
|
||||
controls.forEach(control => {
|
||||
expect(control.config.renderTrigger).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"geojson": "^0.5.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"zod": "^4.4.3",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"currencyformatter.js": "^1.0.5",
|
||||
"handlebars-group-by": "^1.0.1",
|
||||
"just-handlebars-helpers": "^1.0.19",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/chart-controls": "*",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"d3-cloud": "^1.2.9",
|
||||
"d3-scale": "^4.0.2",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"underscore": "^1.13.7",
|
||||
"urijs": "^1.19.11",
|
||||
"xss": "^1.0.15",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mapbox__geojson-extent": "^1.0.3",
|
||||
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry, QueryData, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
getExtensionsRegistry,
|
||||
JsonObject,
|
||||
QueryData,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
css,
|
||||
styled,
|
||||
@@ -58,6 +63,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & {
|
||||
filters: object;
|
||||
handleToggleFullSize: () => void;
|
||||
formData: object;
|
||||
ownState?: JsonObject;
|
||||
width: number;
|
||||
height: number;
|
||||
queriedDttm?: string | null;
|
||||
@@ -174,6 +180,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
height,
|
||||
exportPivotExcel = () => ({}),
|
||||
chartHolderRef,
|
||||
ownState,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -381,6 +388,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
crossFiltersEnabled={isCrossFiltersEnabled}
|
||||
exportPivotExcel={exportPivotExcel}
|
||||
chartHolderRef={chartHolderRef}
|
||||
ownState={ownState}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getChartMetadataRegistry,
|
||||
VizType,
|
||||
BinaryQueryObjectFilterClause,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { css, useTheme, styled } from '@apache-superset/core/theme';
|
||||
@@ -140,6 +141,8 @@ export interface SliceHeaderControlsProps {
|
||||
supersetCanDownload?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
||||
RouteComponentProps;
|
||||
@@ -486,7 +489,12 @@ const SliceHeaderControls = (
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
|
||||
modalBody={
|
||||
<ViewQueryModal
|
||||
latestQueryFormData={props.formData}
|
||||
ownState={props.ownState}
|
||||
/>
|
||||
}
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
|
||||
export interface SliceHeaderControlsProps {
|
||||
slice: {
|
||||
@@ -60,4 +60,5 @@ export interface SliceHeaderControlsProps {
|
||||
supersetCanDownload?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
@@ -480,6 +480,26 @@ const Chart = (props: ChartProps) => {
|
||||
|
||||
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
||||
|
||||
// Memoize ownState so it keeps a stable reference across re-renders that
|
||||
// don't change its logical value. ViewQueryModal depends on ownState; a fresh
|
||||
// object on every render would refetch the query unnecessarily.
|
||||
const ownState = useMemo(
|
||||
() =>
|
||||
createOwnStateWithChartState(
|
||||
(dataMaskOwnState as JsonObject) || EMPTY_OBJECT,
|
||||
{
|
||||
state:
|
||||
getChartStateWithFallback(
|
||||
chartState as { state?: JsonObject } | undefined,
|
||||
formData as JsonObject,
|
||||
sliceVizType,
|
||||
) ?? undefined,
|
||||
},
|
||||
sliceVizType,
|
||||
),
|
||||
[dataMaskOwnState, chartState, formData, sliceVizType],
|
||||
);
|
||||
|
||||
const exportTable = useCallback(
|
||||
async (format: string, isFullCSV: boolean, isPivot = false) => {
|
||||
const logAction =
|
||||
@@ -727,6 +747,7 @@ const Chart = (props: ChartProps) => {
|
||||
height={getHeaderHeight()}
|
||||
exportPivotExcel={exportPivotExcel as unknown as (arg0: string) => void}
|
||||
chartHolderRef={props.chartHolderRef}
|
||||
ownState={ownState}
|
||||
/>
|
||||
|
||||
{/*
|
||||
@@ -777,18 +798,7 @@ const Chart = (props: ChartProps) => {
|
||||
formData={
|
||||
formData as unknown as import('@superset-ui/core').QueryFormData
|
||||
}
|
||||
ownState={createOwnStateWithChartState(
|
||||
(dataMask[props.id]?.ownState as JsonObject) || EMPTY_OBJECT,
|
||||
{
|
||||
state:
|
||||
getChartStateWithFallback(
|
||||
chartState as { state?: JsonObject } | undefined,
|
||||
formData as JsonObject,
|
||||
slice.viz_type,
|
||||
) ?? undefined,
|
||||
},
|
||||
slice.viz_type,
|
||||
)}
|
||||
ownState={ownState}
|
||||
queriesResponse={chart.queriesResponse ?? null}
|
||||
timeout={timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import { screen, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import * as chartAction from 'src/components/Chart/chartAction';
|
||||
import type { ChartDataRequestResponse } from 'src/components/Chart/chartAction';
|
||||
import ViewQueryModal from './ViewQueryModal';
|
||||
|
||||
const mockFormData = {
|
||||
@@ -26,9 +28,19 @@ const mockFormData = {
|
||||
viz_type: 'table',
|
||||
};
|
||||
|
||||
// Minimal, type-correct response that satisfies ChartDataRequestResponse.
|
||||
// A real Response instance avoids the 16 required Response fields that an
|
||||
// empty object ({}) fails to overlap. The assertions only inspect the call
|
||||
// arguments, never the resolved value's contents.
|
||||
const mockChartDataResponse: ChartDataRequestResponse = {
|
||||
response: new Response(),
|
||||
json: { result: [] },
|
||||
};
|
||||
|
||||
const chartDataEndpoint = 'glob:*/api/v1/chart/data*';
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
@@ -117,3 +129,91 @@ test('renders both Alert and SQL query when parsing error occurs', async () => {
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
test('passes ownState through to getChartDataRequest', async () => {
|
||||
/**
|
||||
* Regression test for PR #35208 - the ViewQueryModal must forward the
|
||||
* chart's ownState (e.g. table search text, order_by) to the data request
|
||||
* so that the displayed SQL reflects the same filters applied to the chart.
|
||||
*/
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(chartAction, 'getChartDataRequest')
|
||||
.mockResolvedValue(mockChartDataResponse);
|
||||
|
||||
const ownState = { searchText: 'foo', order_by: [['col', 'asc']] };
|
||||
|
||||
render(
|
||||
<ViewQueryModal latestQueryFormData={mockFormData} ownState={ownState} />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: mockFormData,
|
||||
ownState,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('strips clientView from ownState before the query request', async () => {
|
||||
/**
|
||||
* clientView holds the full client-side row/column snapshot (added by
|
||||
* TableChart) and is irrelevant to SQL generation. It must be stripped
|
||||
* before the request - matching ExploreViewContainer and Dashboard - to
|
||||
* avoid bloating the payload (or triggering 413) on large tables.
|
||||
*/
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(chartAction, 'getChartDataRequest')
|
||||
.mockResolvedValue(mockChartDataResponse);
|
||||
|
||||
const ownState = {
|
||||
searchText: 'foo',
|
||||
// Simulate a large client-side snapshot that TableChart writes
|
||||
clientView: { rows: [{ a: 1 }, { a: 2 }], columns: ['a'] },
|
||||
};
|
||||
|
||||
render(
|
||||
<ViewQueryModal latestQueryFormData={mockFormData} ownState={ownState} />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const calledOwnState = getChartDataRequestSpy.mock.calls[0][0].ownState;
|
||||
expect(calledOwnState).not.toHaveProperty('clientView');
|
||||
expect(calledOwnState).toEqual(
|
||||
expect.objectContaining({ searchText: 'foo' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to empty ownState when prop is omitted', async () => {
|
||||
/**
|
||||
* Covers the `ownState || {}` fallback branch in ViewQueryModal - when no
|
||||
* ownState is provided, the data request must still be called with an empty
|
||||
* object rather than undefined, matching getChartDataRequest's contract.
|
||||
*/
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(chartAction, 'getChartDataRequest')
|
||||
.mockResolvedValue(mockChartDataResponse);
|
||||
|
||||
render(<ViewQueryModal latestQueryFormData={mockFormData} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: mockFormData,
|
||||
ownState: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FC, Fragment, useEffect, useState } from 'react';
|
||||
import { FC, Fragment, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
ensureIsArray,
|
||||
getClientErrorObject,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
@@ -33,6 +35,7 @@ import ViewQuery from 'src/explore/components/controls/ViewQuery';
|
||||
|
||||
interface Props {
|
||||
latestQueryFormData: QueryFormData;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
type Result = {
|
||||
@@ -48,38 +51,47 @@ const ViewQueryModalContainer = styled.div`
|
||||
gap: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
|
||||
const ViewQueryModal: FC<Props> = ({ latestQueryFormData, ownState }) => {
|
||||
const [result, setResult] = useState<Result[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadChartData = (resultType: string) => {
|
||||
setIsLoading(true);
|
||||
getChartDataRequest({
|
||||
formData: latestQueryFormData,
|
||||
resultFormat: 'json',
|
||||
resultType,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setResult(ensureIsArray(json.result) as Result[]);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
const loadChartData = useCallback(
|
||||
(resultType: string) => {
|
||||
setIsLoading(true);
|
||||
// Strip clientView (client-side row/column snapshot) from ownState before
|
||||
// requesting the query, matching the chart query path in ExploreViewContainer
|
||||
// and Dashboard's activeAllDashboardFilters. clientView is irrelevant to SQL
|
||||
// generation and can bloat the payload (or trigger 413) on large tables.
|
||||
const ownStateForQuery = omit(ownState, ['clientView']) || {};
|
||||
getChartDataRequest({
|
||||
formData: latestQueryFormData,
|
||||
resultFormat: 'json',
|
||||
resultType,
|
||||
ownState: ownStateForQuery,
|
||||
})
|
||||
.catch(response => {
|
||||
getClientErrorObject(response).then(({ error, message }) => {
|
||||
setError(
|
||||
error ||
|
||||
message ||
|
||||
response.statusText ||
|
||||
t('Sorry, An error occurred'),
|
||||
);
|
||||
.then(({ json }) => {
|
||||
setResult(ensureIsArray(json.result) as Result[]);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
})
|
||||
.catch(response => {
|
||||
getClientErrorObject(response).then(({ error, message }) => {
|
||||
setError(
|
||||
error ||
|
||||
message ||
|
||||
response.statusText ||
|
||||
t('Sorry, An error occurred'),
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
[latestQueryFormData, ownState],
|
||||
);
|
||||
useEffect(() => {
|
||||
loadChartData('query');
|
||||
}, [JSON.stringify(latestQueryFormData)]);
|
||||
}, [loadChartData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
|
||||
@@ -1067,6 +1067,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
modalBody={
|
||||
<ViewQueryModal
|
||||
latestQueryFormData={latestQueryFormData as QueryFormData}
|
||||
ownState={ownState}
|
||||
/>
|
||||
}
|
||||
draggable
|
||||
|
||||
@@ -108,6 +108,7 @@ export const AlertReportCronScheduler: FC<AlertReportCronSchedulerProps> = ({
|
||||
<Input
|
||||
type="text"
|
||||
name="crontab"
|
||||
aria-label={t('Schedule')}
|
||||
style={error ? { borderColor: theme.colorError } : {}}
|
||||
placeholder={t('CRON expression')}
|
||||
value={value}
|
||||
|
||||
+1
@@ -71,6 +71,7 @@ export const TableCatalog = ({
|
||||
/>
|
||||
{tableCatalog?.length > 1 && (
|
||||
<Icons.CloseOutlined
|
||||
aria-label={t('Remove sheet')}
|
||||
css={(theme: SupersetTheme) => css`
|
||||
align-self: center;
|
||||
background: ${theme.colorFillSecondary};
|
||||
|
||||
@@ -1804,8 +1804,17 @@ describe('DatabaseModal', () => {
|
||||
|
||||
userEvent.click(screen.getByTestId('sqla-connect-btn'));
|
||||
|
||||
expect(await screen.findByTestId('database-name-input')).toBeVisible();
|
||||
expect(screen.getByTestId('sqlalchemy-uri-input')).toBeVisible();
|
||||
// assert on presence rather than visibility: the SQLAlchemy form mounts
|
||||
// inside an animated tab pane, and rc-motion's animation state in jsdom
|
||||
// is nondeterministic, so toBeVisible flakes while the form is in fact
|
||||
// rendered (see the animated={{ tabPane: true }} Tabs in DatabaseModal)
|
||||
const nameInput = await screen.findByTestId('database-name-input');
|
||||
const uriInput = screen.getByTestId('sqlalchemy-uri-input');
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
expect(uriInput).toBeInTheDocument();
|
||||
// also confirm the form is actually usable, not just present
|
||||
expect(nameInput).toBeEnabled();
|
||||
expect(uriInput).toBeEnabled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
@@ -64,34 +64,53 @@ export default function initPreamble(): Promise<void> {
|
||||
// Setup SupersetClient early so we can fetch language pack
|
||||
setupClient({ appRoot: applicationRoot() });
|
||||
|
||||
// Load language pack before rendering
|
||||
// Use native fetch to avoid race condition with SupersetClient initialization
|
||||
// Load language pack before rendering.
|
||||
// Prefer the bootstrap-injected pack (stashed on window by the inline
|
||||
// script in spa.html, sourced from common.language_pack) so
|
||||
// module-level `const X = t('...')` calls in code-split chunks all
|
||||
// see a configured translator. Fall back to the async fetch only
|
||||
// when the bootstrap payload didn't carry the pack (e.g. embedded
|
||||
// or a legacy entry that doesn't extend spa.html). See issue #35330.
|
||||
if (lang !== 'en') {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, LANGUAGE_PACK_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const languagePackUrl = makeUrl(`/language_pack/${lang}/`);
|
||||
const resp = await fetch(languagePackUrl, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch language pack: ${resp.status}`);
|
||||
}
|
||||
const json = await resp.json();
|
||||
configure({ languagePack: json as LanguagePack });
|
||||
const bootstrapPack =
|
||||
(bootstrapData.common as { language_pack?: LanguagePack })
|
||||
.language_pack ??
|
||||
(typeof window !== 'undefined'
|
||||
? window.__SUPERSET_LANGUAGE_PACK__
|
||||
: undefined);
|
||||
if (bootstrapPack) {
|
||||
configure({ languagePack: bootstrapPack });
|
||||
dayjs.locale(lang);
|
||||
} catch (err) {
|
||||
logging.warn(
|
||||
'Failed to fetch language pack, falling back to default.',
|
||||
err,
|
||||
);
|
||||
configure();
|
||||
dayjs.locale('en');
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
} else {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, LANGUAGE_PACK_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const languagePackUrl = makeUrl(`/language_pack/${lang}/`);
|
||||
const resp = await fetch(languagePackUrl, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch language pack: ${resp.status}`);
|
||||
}
|
||||
const json = await resp.json();
|
||||
configure({ languagePack: json as LanguagePack });
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = json as LanguagePack;
|
||||
}
|
||||
dayjs.locale(lang);
|
||||
} catch (err) {
|
||||
logging.warn(
|
||||
'Failed to fetch language pack, falling back to default.',
|
||||
err,
|
||||
);
|
||||
configure();
|
||||
dayjs.locale('en');
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+105
-5
@@ -35,6 +35,7 @@ from superset.charts.filters import (
|
||||
ChartAllTextFilter,
|
||||
ChartCertifiedFilter,
|
||||
ChartCreatedByMeFilter,
|
||||
ChartDeletedStateFilter,
|
||||
ChartFavoriteFilter,
|
||||
ChartFilter,
|
||||
ChartHasCreatedByFilter,
|
||||
@@ -64,12 +65,14 @@ from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartInvalidError,
|
||||
ChartNotFoundError,
|
||||
ChartRestoreFailedError,
|
||||
ChartUpdateFailedError,
|
||||
DashboardsForbiddenError,
|
||||
)
|
||||
from superset.commands.chart.export import ExportChartsCommand
|
||||
from superset.commands.chart.fave import AddFavoriteChartCommand
|
||||
from superset.commands.chart.importers.dispatcher import ImportChartsCommand
|
||||
from superset.commands.chart.restore import RestoreChartCommand
|
||||
from superset.commands.chart.unfave import DelFavoriteChartCommand
|
||||
from superset.commands.chart.update import UpdateChartCommand
|
||||
from superset.commands.chart.warm_up_cache import ChartWarmUpCacheCommand
|
||||
@@ -102,12 +105,16 @@ from superset.views.base_api import (
|
||||
requires_json,
|
||||
statsd_metrics,
|
||||
)
|
||||
from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners
|
||||
from superset.views.filters import (
|
||||
BaseFilterRelatedUsers,
|
||||
FilterRelatedOwners,
|
||||
SoftDeleteApiMixin,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChartRestApi(BaseSupersetModelRestApi):
|
||||
class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
datamodel = SQLAInterface(Slice)
|
||||
|
||||
resource_name = "chart"
|
||||
@@ -124,6 +131,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
RouteMethod.IMPORT,
|
||||
RouteMethod.RELATED,
|
||||
"bulk_delete", # not using RouteMethod since locally defined
|
||||
"restore",
|
||||
"viz_types",
|
||||
"favorite_status",
|
||||
"add_favorite",
|
||||
@@ -134,7 +142,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
"warm_up_cache",
|
||||
}
|
||||
class_permission_name = "Chart"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
# Custom methods (``restore``) need an explicit entry; FAB's @protect()
|
||||
# decorator falls back to ``can_<method>_<class>`` (i.e.
|
||||
# ``can_restore_Chart``) when the mapping is missing, which standard
|
||||
# roles don't carry. Mirrors the permission model documented for
|
||||
# ``DELETE`` / ``bulk_delete``: endpoint-level ``can_write`` plus
|
||||
# resource-level ``raise_for_ownership``. See themes/api.py for the
|
||||
# established pattern.
|
||||
method_permission_name = {
|
||||
**MODEL_API_RW_METHOD_PERMISSION_MAP,
|
||||
"restore": "write",
|
||||
}
|
||||
|
||||
list_columns = [
|
||||
"is_managed_externally",
|
||||
@@ -222,6 +240,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
"id": [
|
||||
ChartFavoriteFilter,
|
||||
ChartCertifiedFilter,
|
||||
ChartDeletedStateFilter,
|
||||
ChartOwnedCreatedFavoredByMeFilter,
|
||||
],
|
||||
"slice_name": [ChartAllTextFilter],
|
||||
@@ -471,9 +490,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
)
|
||||
def delete(self, pk: int) -> Response:
|
||||
"""Delete a chart.
|
||||
|
||||
When the ``SOFT_DELETE`` feature flag is enabled, marks the chart as
|
||||
deleted (sets ``deleted_at``) and hides it from list/detail endpoints
|
||||
and relationship loads; the row is preserved and recoverable via
|
||||
``POST /api/v1/chart/<uuid>/restore`` by an owner or admin. With the
|
||||
flag disabled (the default), the chart is permanently hard-deleted
|
||||
and is not recoverable.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a chart
|
||||
summary: Delete a chart (soft delete, recoverable via restore, when
|
||||
the SOFT_DELETE feature flag is enabled; permanent otherwise)
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
@@ -527,9 +554,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
)
|
||||
def bulk_delete(self, **kwargs: Any) -> Response:
|
||||
"""Bulk delete charts.
|
||||
|
||||
When the ``SOFT_DELETE`` feature flag is enabled, marks each chart as
|
||||
deleted (sets ``deleted_at``) and hides it from list/detail endpoints
|
||||
and relationship loads; rows are preserved and recoverable via
|
||||
``POST /api/v1/chart/<uuid>/restore`` by an owner or admin. With the
|
||||
flag disabled (the default), the charts are permanently hard-deleted
|
||||
and are not recoverable.
|
||||
---
|
||||
delete:
|
||||
summary: Bulk delete charts
|
||||
summary: Bulk delete charts (soft delete, recoverable via restore,
|
||||
when the SOFT_DELETE feature flag is enabled; permanent otherwise)
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
@@ -574,6 +609,62 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
except ChartDeleteFailedError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/<uuid>/restore", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.restore",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def restore(self, uuid: str) -> Response:
|
||||
"""Restore a soft-deleted chart.
|
||||
---
|
||||
post:
|
||||
summary: Restore a soft-deleted chart
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
name: uuid
|
||||
responses:
|
||||
200:
|
||||
description: Chart restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
RestoreChartCommand(uuid).run()
|
||||
return self.response(200, message="OK")
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
except ChartForbiddenError:
|
||||
return self.response_403()
|
||||
except ChartRestoreFailedError as ex:
|
||||
logger.error(
|
||||
"Error restoring model %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/<pk>/cache_screenshot/", methods=("GET",))
|
||||
@protect()
|
||||
@parse_rison(screenshot_query_schema)
|
||||
@@ -1103,6 +1194,15 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
@requires_form_data
|
||||
def import_(self) -> Response:
|
||||
"""Import chart(s) with associated datasets and databases.
|
||||
|
||||
When the ``SOFT_DELETE`` feature flag is enabled and an imported
|
||||
chart's UUID matches an existing **soft-deleted** chart, the import
|
||||
restores that chart and applies the upload's contents — **even when
|
||||
``overwrite`` is not set**. Active charts keep the usual contract
|
||||
(never mutated without ``overwrite=true``); a soft-deleted UUID match
|
||||
is treated as an explicit request to bring the chart back. Requires
|
||||
``can_write`` and ownership of the deleted row (or admin). See
|
||||
UPDATING.md for details.
|
||||
---
|
||||
post:
|
||||
summary: Import chart(s) with associated datasets and databases
|
||||
|
||||
@@ -32,6 +32,7 @@ from superset.utils.core import get_user_id
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
from superset.views.base import BaseFilter
|
||||
from superset.views.base_api import BaseFavoriteFilter
|
||||
from superset.views.filters import BaseDeletedStateFilter
|
||||
|
||||
|
||||
class ChartAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
@@ -196,3 +197,40 @@ class ChartOwnedCreatedFavoredByMeFilter(BaseFilter): # pylint: disable=too-few
|
||||
FavStar.user_id == get_user_id(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ChartDeletedStateFilter( # pylint: disable=too-few-public-methods
|
||||
BaseDeletedStateFilter
|
||||
):
|
||||
"""Rison filter for the GET list that exposes soft-deleted charts.
|
||||
|
||||
Soft-deleted rows are additionally scoped to the **restore audience**: only
|
||||
the chart's owners (or admins) may enumerate them. This mirrors
|
||||
``RestoreChartCommand``'s ``raise_for_ownership`` check, so a read-access
|
||||
non-owner (who can see the chart via datasource access) cannot list
|
||||
soft-deleted charts they could never restore. Live rows are unaffected —
|
||||
they keep their normal ``ChartFilter`` visibility. The ownership scoping is
|
||||
part of the cross-entity deleted-state contract: only the restore audience
|
||||
may enumerate soft-deleted rows — any entity exposing a deleted-state
|
||||
filter is expected to apply the same scoping.
|
||||
"""
|
||||
|
||||
arg_name = "chart_deleted_state"
|
||||
model = Slice
|
||||
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
query = super().apply(query, value)
|
||||
normalized = self._normalize(value)
|
||||
if normalized not in {"include", "only"} or security_manager.is_admin():
|
||||
return query
|
||||
|
||||
# Non-admins may only see soft-deleted charts they own. ``any()`` emits
|
||||
# an EXISTS subquery so it composes with the base access filter without
|
||||
# producing duplicate rows from a join.
|
||||
owned = Slice.owners.any(security_manager.user_model.id == get_user_id())
|
||||
if normalized == "only":
|
||||
# ``super().apply`` already restricted to ``deleted_at IS NOT NULL``.
|
||||
return query.filter(owned)
|
||||
# ``include``: keep all live rows (normal access) and add only the
|
||||
# soft-deleted rows this user owns.
|
||||
return query.filter(or_(Slice.deleted_at.is_(None), owned))
|
||||
|
||||
@@ -123,6 +123,16 @@ class ChartDeleteFailedError(DeleteFailedError):
|
||||
message = _("Charts could not be deleted.")
|
||||
|
||||
|
||||
class ChartRestoreFailedError(UpdateFailedError):
|
||||
# Restore semantically clears ``deleted_at``; it is an UPDATE, not a new
|
||||
# row. ``UpdateFailedError`` is the nearest typed middle-tier base in the
|
||||
# codebase. A dedicated ``RestoreFailedError`` in
|
||||
# ``superset/commands/exceptions.py`` would be more precise across the
|
||||
# entity rollouts but lives in already-merged infrastructure (#39977);
|
||||
# introducing it can be a cross-entity follow-up.
|
||||
message = _("Chart could not be restored.")
|
||||
|
||||
|
||||
class ChartDeleteFailedReportsExistError(ChartDeleteFailedError):
|
||||
message = _("There are associated alerts or reports")
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.commands.exceptions import ImportFailedError
|
||||
from superset.commands.importers.v1.utils import find_existing_for_import
|
||||
from superset.migrations.shared.migrate_viz import processors
|
||||
from superset.migrations.shared.migrate_viz.base import MigrateViz
|
||||
from superset.models.slice import Slice
|
||||
@@ -48,21 +49,107 @@ def import_chart(
|
||||
overwrite: bool = False,
|
||||
ignore_permissions: bool = False,
|
||||
) -> Slice:
|
||||
"""Import a chart from a config dict, handling existing matches.
|
||||
|
||||
Permission model for an existing UUID match:
|
||||
|
||||
+--------------+---------------+---------------------+-----------------+
|
||||
| Existing row | overwrite arg | Caller has perms? | Outcome |
|
||||
+==============+===============+=====================+=================+
|
||||
| alive | False | (n/a) | return existing |
|
||||
+--------------+---------------+---------------------+-----------------+
|
||||
| alive | True | can_write + owner | UPDATE in place |
|
||||
+--------------+---------------+---------------------+-----------------+
|
||||
| alive | True | can_write, | raise |
|
||||
| | | not owner/admin | |
|
||||
+--------------+---------------+---------------------+-----------------+
|
||||
| soft-deleted | False or True | can_write + owner | restore + UPDATE|
|
||||
+--------------+---------------+---------------------+-----------------+
|
||||
| soft-deleted | False or True | can_write, | raise |
|
||||
| | | not owner/admin | |
|
||||
+--------------+---------------+---------------------+-----------------+
|
||||
| soft-deleted | False or True | not can_write | raise (Case B) |
|
||||
+--------------+---------------+---------------------+-----------------+
|
||||
|
||||
Re-importing a soft-deleted UUID is implicitly a restore-with-update:
|
||||
the user is bringing the chart back by uploading it again. We apply
|
||||
the same ownership check as the explicit overwrite path so non-owners
|
||||
cannot resurrect via re-import, and we raise rather than silently
|
||||
returning a soft-deleted row to callers without write permission
|
||||
(which would let them reattach dashboards to a deleted chart).
|
||||
"""
|
||||
can_write = ignore_permissions or security_manager.can_access("can_write", "Chart")
|
||||
existing = db.session.query(Slice).filter_by(uuid=config["uuid"]).first()
|
||||
# `user` is None for background / example-loader paths (no Flask request
|
||||
# user). Combined with ``can_write=True`` (typically from
|
||||
# ``ignore_permissions=True``), the ownership checks in the restore /
|
||||
# overwrite branches below are intentionally skipped because the caller has
|
||||
# already established trust at the command level.
|
||||
user = get_user()
|
||||
if existing:
|
||||
if overwrite and can_write and user:
|
||||
if not security_manager.can_access_chart(existing) or (
|
||||
user not in existing.owners and not security_manager.is_admin()
|
||||
|
||||
if existing := find_existing_for_import(Slice, config["uuid"]):
|
||||
if existing.deleted_at is not None:
|
||||
# RESTORE path — re-importing a soft-deleted UUID is an implicit
|
||||
# restore-with-update, a distinct operation from overwriting an
|
||||
# alive row, so it is handled in its own branch.
|
||||
if not can_write:
|
||||
# Case B: don't silently return a soft-deleted row to a caller
|
||||
# without write permission — that would let the dashboard
|
||||
# importer reattach to a deleted chart and produce a broken
|
||||
# dashboard.
|
||||
# Name the chart: a dashboard bundle imports many charts, and
|
||||
# without the identity the operator can't tell which of N
|
||||
# charts in the bundle hit the soft-deleted match.
|
||||
raise ImportFailedError(
|
||||
f"Chart {existing.slice_name!r} (uuid {config['uuid']}) "
|
||||
f"was deleted and re-import requires can_write "
|
||||
f"permission to restore it"
|
||||
)
|
||||
# ``user`` is None on background / example-loader paths; combined
|
||||
# with ``can_write`` (typically from ``ignore_permissions=True``)
|
||||
# the ownership check is intentionally skipped because the caller
|
||||
# already established trust.
|
||||
if user and (
|
||||
not security_manager.can_access_chart(existing)
|
||||
or (user not in existing.owners and not security_manager.is_admin())
|
||||
):
|
||||
raise ImportFailedError(
|
||||
"A chart already exists and user doesn't "
|
||||
"have permissions to overwrite it"
|
||||
f"Chart {existing.slice_name!r} (uuid {config['uuid']}) "
|
||||
f"already exists and user doesn't have permissions to "
|
||||
f"restore it"
|
||||
)
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
config["id"] = existing.id
|
||||
# Restore in place (clear ``deleted_at``) rather than
|
||||
# hard-delete-and-replace: a hard delete would cascade to
|
||||
# dashboard_slices and other FK references, breaking the dashboards
|
||||
# that previously embedded this chart.
|
||||
#
|
||||
# How the restore lands as an UPDATE: clearing
|
||||
# ``existing.deleted_at`` marks the in-session row dirty and the
|
||||
# explicit flush emits the ``deleted_at = NULL`` UPDATE before
|
||||
# ``Slice.import_from_dict`` (below) does its own query-by-uuid
|
||||
# lookup. Without the flush we would rely on autoflush ahead of that
|
||||
# internal query — correct under default session config but a hidden
|
||||
# contract; the explicit flush makes it robust. The lookup then
|
||||
# finds the now-live row (the listener filters ``deleted_at IS
|
||||
# NULL``) and ``import_from_dict`` applies the config as field
|
||||
# updates on the existing object, preserving the PK.
|
||||
existing.restore()
|
||||
db.session.flush()
|
||||
config["id"] = existing.id
|
||||
else:
|
||||
# OVERWRITE path — existing alive row. Without ``overwrite`` or
|
||||
# write permission, return it unchanged (the pre-soft-delete
|
||||
# overwrite-without-permission behaviour).
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
if user and (
|
||||
not security_manager.can_access_chart(existing)
|
||||
or (user not in existing.owners and not security_manager.is_admin())
|
||||
):
|
||||
raise ImportFailedError(
|
||||
"A chart already exists and user doesn't have "
|
||||
"permissions to overwrite it"
|
||||
)
|
||||
config["id"] = existing.id
|
||||
elif not can_write:
|
||||
raise ImportFailedError(
|
||||
"Chart doesn't exist and user doesn't have permission to create charts"
|
||||
@@ -80,7 +167,7 @@ def import_chart(
|
||||
if chart.id is None:
|
||||
db.session.flush()
|
||||
|
||||
if (user := get_user()) and user not in chart.owners:
|
||||
if user and user not in chart.owners:
|
||||
chart.owners.append(user)
|
||||
|
||||
return chart
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
"""Command to restore a soft-deleted chart."""
|
||||
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartNotFoundError,
|
||||
ChartRestoreFailedError,
|
||||
)
|
||||
from superset.commands.restore import BaseRestoreCommand
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.models.slice import Slice
|
||||
|
||||
|
||||
class RestoreChartCommand(BaseRestoreCommand[Slice]):
|
||||
"""Restore a soft-deleted chart by clearing its ``deleted_at`` field.
|
||||
|
||||
All transactional wrapping and validation lives in
|
||||
``BaseRestoreCommand``; this subclass is purely declarative.
|
||||
"""
|
||||
|
||||
dao = ChartDAO
|
||||
not_found_exc = ChartNotFoundError
|
||||
forbidden_exc = ChartForbiddenError
|
||||
restore_failed_exc = ChartRestoreFailedError
|
||||
@@ -225,6 +225,20 @@ class ReportScheduleExecuteUnexpectedError(CommandException):
|
||||
message = _("Report Schedule execution got an unexpected error.")
|
||||
|
||||
|
||||
class ReportScheduleTargetChartDeletedError(CommandException):
|
||||
message = _(
|
||||
"The chart this report targets was deleted. Restore the chart, or "
|
||||
"update the report to point at an active chart."
|
||||
)
|
||||
|
||||
|
||||
class ReportScheduleTargetDashboardDeletedError(CommandException):
|
||||
message = _(
|
||||
"The dashboard this report targets was deleted. Restore the "
|
||||
"dashboard, or update the report to point at an active dashboard."
|
||||
)
|
||||
|
||||
|
||||
class ReportSchedulePreviousWorkingError(CommandException):
|
||||
status = 429
|
||||
message = _("Report Schedule is still working, refusing to re-compute.")
|
||||
|
||||
@@ -44,6 +44,8 @@ from superset.commands.report.exceptions import (
|
||||
ReportScheduleScreenshotTimeout,
|
||||
ReportScheduleStateNotFoundError,
|
||||
ReportScheduleSystemErrorsException,
|
||||
ReportScheduleTargetChartDeletedError,
|
||||
ReportScheduleTargetDashboardDeletedError,
|
||||
ReportScheduleUnexpectedError,
|
||||
ReportScheduleWorkingTimeoutError,
|
||||
)
|
||||
@@ -256,6 +258,32 @@ class BaseReportState:
|
||||
"""
|
||||
Get the url for this report schedule: chart or dashboard
|
||||
"""
|
||||
# Soft delete removed the FK-level guarantee that a report's target
|
||||
# chart exists: ``chart`` is a visibility-filtered relationship, so a
|
||||
# chart soft-deleted after this report was created (or attached via a
|
||||
# validate/commit race with DeleteChartCommand) loads as ``None``
|
||||
# while ``chart_id`` is still set. Without this guard the branch
|
||||
# below silently falls through to the dashboard path and fails
|
||||
# opaquely; raising here surfaces a clear, actionable error inside
|
||||
# the state-machine envelope (ERROR log row + owner notification).
|
||||
# Every content path (_get_screenshots, _get_csv_data,
|
||||
# _get_embedded_data, _get_notification_content) funnels through this
|
||||
# method, so this is the single choke point.
|
||||
if (
|
||||
self._report_schedule.chart_id is not None
|
||||
and self._report_schedule.chart is None
|
||||
):
|
||||
raise ReportScheduleTargetChartDeletedError()
|
||||
# Symmetric guard for dashboard targets. Dashboard soft delete lands
|
||||
# in the sibling rollout; until then this cannot fire (a dashboard
|
||||
# with dependent reports cannot be deleted), which makes it inert
|
||||
# rather than wrong — and it keeps the report-target error vocabulary
|
||||
# parallel across entities from day one.
|
||||
if (
|
||||
self._report_schedule.dashboard_id is not None
|
||||
and self._report_schedule.dashboard is None
|
||||
):
|
||||
raise ReportScheduleTargetDashboardDeletedError()
|
||||
force = "true" if self._report_schedule.force_screenshot else "false"
|
||||
if self._report_schedule.chart:
|
||||
if result_format in {
|
||||
@@ -300,6 +328,14 @@ class BaseReportState:
|
||||
"""
|
||||
Retrieve the URL for the dashboard tabs, or return the dashboard URL if no tabs are available.
|
||||
""" # noqa: E501
|
||||
# Called directly from AsyncExecuteReportScheduleCommand.run (permalink
|
||||
# pre-commit) without passing through _get_url, so it needs the same
|
||||
# deleted-target guard.
|
||||
if (
|
||||
self._report_schedule.dashboard_id is not None
|
||||
and self._report_schedule.dashboard is None
|
||||
):
|
||||
raise ReportScheduleTargetDashboardDeletedError()
|
||||
force = "true" if self._report_schedule.force_screenshot else "false"
|
||||
|
||||
if (
|
||||
|
||||
@@ -40,6 +40,7 @@ from superset.extensions import db
|
||||
from superset.models.core import FavStar, FavStarClassName
|
||||
from superset.models.dashboard import Dashboard, dashboard_user, id_or_slug_filter
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.models.helpers import skip_visibility_filter
|
||||
from superset.models.slice import Slice
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user_id
|
||||
@@ -294,11 +295,30 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
if isinstance(value, dict)
|
||||
]
|
||||
|
||||
current_slices = (
|
||||
db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
|
||||
)
|
||||
|
||||
dashboard.slices = current_slices
|
||||
# Bypass the soft-delete visibility filter when resolving the
|
||||
# incoming chart ids: a dashboard's ``position_json`` may still
|
||||
# reference a chart that is currently soft-deleted, and this
|
||||
# assignment REBUILDS ``dashboard.slices`` wholesale. With the
|
||||
# filter active, the hidden member would be silently dropped —
|
||||
# deleting its ``dashboard_slices`` junction row (breaking the
|
||||
# documented restore-reattach contract) and writing
|
||||
# ``uuid: None`` into its position slot via ``uuid_map`` below.
|
||||
#
|
||||
# The bypass must be session-scoped and cover the ASSIGNMENT,
|
||||
# not just the resolution query: assigning to
|
||||
# ``dashboard.slices`` makes the unit of work diff the new
|
||||
# collection against the existing one, which it lazy-loads at
|
||||
# that moment. A filtered baseline load would exclude the
|
||||
# trashed member, so the diff would treat it as net-new and
|
||||
# INSERT a ``dashboard_slices`` row that still exists (soft
|
||||
# delete never removes junction rows) — an IntegrityError on
|
||||
# the composite primary key on every save of a dashboard
|
||||
# containing a trashed chart.
|
||||
with skip_visibility_filter(db.session, Slice):
|
||||
current_slices = (
|
||||
db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
|
||||
)
|
||||
dashboard.slices = current_slices
|
||||
|
||||
# add UUID to positions
|
||||
uuid_map = {slice.id: str(slice.uuid) for slice in current_slices}
|
||||
|
||||
@@ -706,8 +706,8 @@ class GenerateDashboardRequest(BaseModel):
|
||||
max_length=255,
|
||||
description=(
|
||||
"Optional URL slug for the dashboard. When set, the dashboard "
|
||||
"is reachable at /superset/dashboard/<slug>/ instead of "
|
||||
"/superset/dashboard/<id>/. Must be unique across the instance."
|
||||
"is reachable at /dashboard/<slug>/ instead of "
|
||||
"/dashboard/<id>/. Must be unique across the instance."
|
||||
),
|
||||
)
|
||||
position_json: Dict[str, Any] | None = Field(
|
||||
|
||||
@@ -116,7 +116,7 @@ def _serialize_new_dashboard(dashboard: Any) -> tuple[DashboardInfo, str]:
|
||||
"""Build the response ``DashboardInfo`` and URL for the new dashboard."""
|
||||
from superset.mcp_service.dashboard.schemas import serialize_tag_object
|
||||
|
||||
dashboard_url = f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/"
|
||||
dashboard_url = f"{get_superset_base_url()}/dashboard/{dashboard.id}/"
|
||||
include_data_model_metadata = user_can_view_data_model_metadata()
|
||||
info = DashboardInfo(
|
||||
id=dashboard.id,
|
||||
@@ -202,9 +202,7 @@ def _refetch_and_serialize(
|
||||
exc_info=True,
|
||||
)
|
||||
_safe_rollback("dashboard re-fetch")
|
||||
dashboard_url = (
|
||||
f"{get_superset_base_url()}/superset/dashboard/{new_dashboard.id}/"
|
||||
)
|
||||
dashboard_url = f"{get_superset_base_url()}/dashboard/{new_dashboard.id}/"
|
||||
info = _sanitize_dashboard_info_for_llm_context(
|
||||
DashboardInfo(
|
||||
id=new_dashboard.id,
|
||||
|
||||
@@ -452,9 +452,7 @@ def manage_native_filters(
|
||||
request.dashboard_id, payload
|
||||
).run()
|
||||
|
||||
dashboard_url = (
|
||||
f"{get_superset_base_url()}/superset/dashboard/{request.dashboard_id}/"
|
||||
)
|
||||
dashboard_url = f"{get_superset_base_url()}/dashboard/{request.dashboard_id}/"
|
||||
logger.info(
|
||||
"Managed native filters on dashboard %s (added=%d updated=%d removed=%d)",
|
||||
request.dashboard_id,
|
||||
|
||||
@@ -391,7 +391,7 @@ def remove_chart_from_dashboard( # noqa: C901 — complexity is structural (lay
|
||||
exc_info=True,
|
||||
)
|
||||
dashboard_url = (
|
||||
f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/"
|
||||
f"{get_superset_base_url()}/dashboard/{updated_dashboard.id}/"
|
||||
)
|
||||
return RemoveChartFromDashboardResponse(
|
||||
dashboard=DashboardInfo(
|
||||
@@ -466,7 +466,7 @@ def remove_chart_from_dashboard( # noqa: C901 — complexity is structural (lay
|
||||
created_on=updated_dashboard.created_on,
|
||||
changed_on=updated_dashboard.changed_on,
|
||||
uuid=str(updated_dashboard.uuid) if updated_dashboard.uuid else None,
|
||||
url=f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/",
|
||||
url=f"{get_superset_base_url()}/dashboard/{updated_dashboard.id}/",
|
||||
chart_count=len(updated_dashboard.slices),
|
||||
tags=[
|
||||
serialize_tag_object(tag)
|
||||
@@ -486,9 +486,7 @@ def remove_chart_from_dashboard( # noqa: C901 — complexity is structural (lay
|
||||
],
|
||||
)
|
||||
|
||||
dashboard_url = (
|
||||
f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/"
|
||||
)
|
||||
dashboard_url = f"{get_superset_base_url()}/dashboard/{updated_dashboard.id}/"
|
||||
|
||||
logger.info(
|
||||
"Removed chart %s from dashboard %s",
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Add deleted_at column and index to slices for soft-delete.
|
||||
|
||||
Adds a nullable ``deleted_at`` column and an index on it to the
|
||||
``slices`` table to support soft deletion of charts. Companion to
|
||||
the ``SoftDeleteMixin`` infrastructure shipped in PR #39977.
|
||||
|
||||
Revision ID: 7c4a8d09ca37
|
||||
Revises: b4a3f2e1d0c9
|
||||
Create Date: 2026-05-08 12:00:00.000000
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
|
||||
from superset.migrations.shared.utils import (
|
||||
add_columns,
|
||||
create_index,
|
||||
drop_columns,
|
||||
drop_index,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7c4a8d09ca37"
|
||||
down_revision = "b4a3f2e1d0c9"
|
||||
|
||||
TABLE_NAME = "slices"
|
||||
INDEX_NAME = f"ix_{TABLE_NAME}_deleted_at"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
add_columns(TABLE_NAME, Column("deleted_at", DateTime(), nullable=True))
|
||||
create_index(TABLE_NAME, INDEX_NAME, ["deleted_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
drop_index(TABLE_NAME, INDEX_NAME)
|
||||
drop_columns(TABLE_NAME, "deleted_at")
|
||||
@@ -43,7 +43,11 @@ from superset_core.common.models import Chart as CoreChart
|
||||
|
||||
from superset import db, is_feature_enabled, security_manager
|
||||
from superset.legacy import update_time_range
|
||||
from superset.models.helpers import AuditMixinNullable, ImportExportMixin
|
||||
from superset.models.helpers import (
|
||||
AuditMixinNullable,
|
||||
ImportExportMixin,
|
||||
SoftDeleteMixin,
|
||||
)
|
||||
from superset.tasks.thumbnails import cache_chart_thumbnail
|
||||
from superset.tasks.utils import get_current_user
|
||||
from superset.thumbnails.digest import get_chart_digest
|
||||
@@ -76,7 +80,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Slice( # pylint: disable=too-many-public-methods
|
||||
CoreChart, AuditMixinNullable, ImportExportMixin
|
||||
CoreChart, SoftDeleteMixin, AuditMixinNullable, ImportExportMixin
|
||||
):
|
||||
"""A slice is essentially a report or a view on data"""
|
||||
|
||||
|
||||
@@ -149,6 +149,36 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block tail_js %}
|
||||
{#
|
||||
Expose the language pack on window BEFORE the entry bundle loads,
|
||||
so module-level `const X = t('...')` calls across webpack-split
|
||||
chunks all find a configured translator on first access. The
|
||||
bootstrap div is the single source of truth; we parse it once and
|
||||
stash the pack on a global that the translator reads lazily.
|
||||
See upstream issue #35330.
|
||||
|
||||
Trade-off: this ships the full Jed pack inline in every full-page
|
||||
HTML response (only for non-English locales; English injects null)
|
||||
rather than via the separately-cacheable `/language_pack/` fetch.
|
||||
That synchronous availability is the whole point — the async fetch
|
||||
can't guarantee the pack is configured before a code-split chunk
|
||||
evaluates. Acceptable for an SPA where hard reloads are rare.
|
||||
#}
|
||||
<script nonce="{{ macros.get_nonce() }}">
|
||||
(function () {
|
||||
try {
|
||||
var el = document.getElementById('app');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
|
||||
var pack = data && data.common && data.common.language_pack;
|
||||
if (pack) {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = pack;
|
||||
}
|
||||
} catch (e) {
|
||||
/* swallow: fall back to async fetch in preamble.ts */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% if entry %}
|
||||
{{ js_bundle(assets_prefix, entry) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -3374,6 +3374,9 @@ msgstr "تغييرات المخطط"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "تعذر إنشاء مخطط."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "تعذر تحديث المخطط."
|
||||
|
||||
@@ -14825,6 +14828,11 @@ msgstr ""
|
||||
"فئة العقد المصدر المستخدمة لتعيين الألوان. إذا كانت العقدة مرتبطة بأكثر "
|
||||
"من فئة واحدة، فسيتم استخدام الأولى فقط."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -14927,6 +14935,11 @@ msgstr "معيار رمز البلد الذي يجب أن تتوقع Superset ا
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "تم حفظ لوحة التحكم"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "يبدو أن مصدر البيانات قد تم حذفه"
|
||||
|
||||
|
||||
@@ -3292,6 +3292,9 @@ msgstr "Canvis del gràfic"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "El gràfic no s'ha pogut crear."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "El gràfic no s'ha pogut actualitzar."
|
||||
|
||||
@@ -14447,6 +14450,11 @@ msgstr ""
|
||||
"La categoria de nodes font usada per assignar colors. Si un node està "
|
||||
"associat amb més d'una categoria, només la primera s'usarà."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -14541,6 +14549,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "El dashboard s'ha desat"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "La font de dades sembla haver estat eliminada"
|
||||
|
||||
|
||||
@@ -3161,6 +3161,9 @@ msgstr "Změny grafu"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Graf se nepodařilo vytvořit."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Graf se nepodařilo aktualizovat."
|
||||
|
||||
@@ -13865,6 +13868,11 @@ msgstr ""
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "Graf se stále načítá. Chvíli počkejte a zkuste to znovu."
|
||||
|
||||
@@ -13951,6 +13959,11 @@ msgstr "Standard kódu země, který by měl Superset očekávat ve sloupci [zem
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Nástěnka byla uložena"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Zdá se, že zdroj dat byl smazán"
|
||||
|
||||
|
||||
@@ -3105,6 +3105,9 @@ msgstr "Diagrammänderungen"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Diagramm konnte nicht erstellt werden."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Diagramm konnte nicht aktualisiert werden."
|
||||
|
||||
@@ -13455,6 +13458,11 @@ msgstr ""
|
||||
"werden. Wenn ein Knoten mehr als einer Kategorie zugeordnet ist, wird nur"
|
||||
" die erste verwendet."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr ""
|
||||
"Das Diagramm wird noch geladen. Bitte warten Sie einen Moment und "
|
||||
@@ -13545,6 +13553,11 @@ msgstr "Der Ländercodestandard, den Superset in der Spalte [Land] erwarten soll
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Dashboard wurde erfolgreich gespeichert"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Die Datenquelle scheint gelöscht worden zu sein"
|
||||
|
||||
|
||||
@@ -2791,6 +2791,9 @@ msgstr ""
|
||||
msgid "Chart could not be created."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr ""
|
||||
|
||||
@@ -12366,6 +12369,11 @@ msgid ""
|
||||
"associated with more than one category, only the first will be used."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -12437,6 +12445,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -3252,6 +3252,9 @@ msgstr "Cambios en el gráfico"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "No se ha podido crear el gráfico."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "No se ha podido actualizar el gráfico."
|
||||
|
||||
@@ -14378,6 +14381,11 @@ msgstr ""
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr ""
|
||||
"El gráfico todavía se está cargando. Por favor, espere un momento e "
|
||||
@@ -14472,6 +14480,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "El panel de control se ha guardado"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Parece que se ha eliminado la fuente de datos"
|
||||
|
||||
|
||||
@@ -3274,6 +3274,9 @@ msgstr "تغییرات نمودار"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "نمودار نتوانسته ایجاد شود."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "نمودار نتوانست بهروزرسانی شود."
|
||||
|
||||
@@ -14532,6 +14535,11 @@ msgstr ""
|
||||
"دستهبندی گرههای منبع که برای اختصاص رنگها استفاده میشود. اگر یک گره "
|
||||
"با بیش از یک دسته مرتبط باشد، تنها اولین دسته استفاده خواهد شد."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -14633,6 +14641,11 @@ msgstr "استاندارد کد کشور که سوپرسِت باید در ست
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "داشبورد ذخیره شد"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "به نظر میرسد منبع داده حذف شده است."
|
||||
|
||||
|
||||
@@ -5396,6 +5396,9 @@ msgstr "Kaaviota ei voitu luoda."
|
||||
# de, es, fa, fr, it, ja, ko, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, tr, uk,
|
||||
# zh, zh_TW]
|
||||
#, fuzzy
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Kaaviota ei voitu päivittää."
|
||||
|
||||
@@ -24396,6 +24399,11 @@ msgstr ""
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "Kaavio latautuu vielä. Odota hetki ja yritä uudelleen."
|
||||
|
||||
@@ -24535,6 +24543,11 @@ msgstr "Maakodistandardi, jonka Superset odottaa löytävänsä [country]-sarakk
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Koontinäyttö on tallennettu"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
#, fuzzy
|
||||
|
||||
@@ -3175,6 +3175,9 @@ msgstr "Changements de graphique"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Le graphique n'a pas pu être créé."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Le graphique n'a pas pu être mis à jour."
|
||||
|
||||
@@ -14327,6 +14330,11 @@ msgstr ""
|
||||
"La catégorie de nœuds sources utilisée pour attribuer des couleurs. Si un "
|
||||
"nœud est associé à plus d’une catégorie, seul le premier sera utilisé."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -14418,6 +14426,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Ce tableau de bord a été sauvegardé"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "La source de données semble avoir été effacée"
|
||||
|
||||
|
||||
@@ -4813,6 +4813,9 @@ msgstr "Ultima Modifica"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "La tua query non può essere salvata"
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "La tua query non può essere salvata"
|
||||
|
||||
@@ -22012,6 +22015,11 @@ msgstr ""
|
||||
"La categoria dei nodi sorgente utilizzata per assegnare i colori. Se un "
|
||||
"nodo è associato a più di una categoria, verrà utilizzata solo la prima."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -22161,6 +22169,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Il dashboard è stato salvato"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ro, ru, sk, sl, sr, sr_Latn,
|
||||
# tr, uk, zh, zh_TW]
|
||||
|
||||
@@ -2886,6 +2886,9 @@ msgstr "チャートの変更"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "チャートを作成できませんでした。"
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "チャートを更新できませんでした。"
|
||||
|
||||
@@ -12807,6 +12810,11 @@ msgstr "色の割り当てに使用されるソースノードのカテゴリ。
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "チャートはまだ読み込み中です。しばらく待ってから再試行してください。"
|
||||
|
||||
@@ -12880,6 +12888,11 @@ msgstr "Supersetが「国」列で想定する国コード規格。"
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "ダッシュボードを保存しました"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "データソースが削除されたようです"
|
||||
|
||||
|
||||
@@ -4737,6 +4737,9 @@ msgstr "차트 변경 사항"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "차트를 생성할 수 없습니다."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "차트를 업데이트할 수 없습니다."
|
||||
|
||||
@@ -21700,6 +21703,11 @@ msgid ""
|
||||
"associated with more than one category, only the first will be used."
|
||||
msgstr "색상을 지정하는 데 사용되는 소스 노드의 카테고리입니다. 노드가 둘 이상의 카테고리와 연결된 경우 첫 번째 카테고리만 사용됩니다."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -21842,6 +21850,11 @@ msgstr "Superset이 [country] 열에서 찾을 것으로 예상하는 국가 코
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "대시보드가 저장되었습니다"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ro, ru, sk, sl, sr, sr_Latn,
|
||||
# tr, uk, zh, zh_TW]
|
||||
|
||||
@@ -3125,6 +3125,9 @@ msgstr "Diagrammas izmaiņas"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Diagrammu nevarēja izveidot."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Diagrammu nevarēja atjaunināt."
|
||||
|
||||
@@ -13548,6 +13551,11 @@ msgstr ""
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "Diagramma vēl tiek ielādēta. Lūdzu, uzgaidiet brīdi un mēģiniet vēlreiz."
|
||||
|
||||
@@ -13633,6 +13641,11 @@ msgstr "Valsts koda standarts, ko Superset sagaida [country] kolonnā"
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Panelis ir saglabāts"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Šķiet, ka datu avots ir dzēsts"
|
||||
|
||||
|
||||
@@ -2798,6 +2798,9 @@ msgstr ""
|
||||
msgid "Chart could not be created."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr ""
|
||||
|
||||
@@ -12351,6 +12354,11 @@ msgid ""
|
||||
"associated with more than one category, only the first will be used."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -12422,6 +12430,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -3247,6 +3247,9 @@ msgstr "Huringa kauwhata"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Kāore i taea te hanga i te kauwhata."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Kāore i taea te whakahou i te kauwhata."
|
||||
|
||||
@@ -14356,6 +14359,11 @@ msgstr ""
|
||||
"hāngai ana tētahi pona ki te nui atu i te kotahi kāwai, ko te tuatahi "
|
||||
"anake ka whakamahia."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -14444,6 +14452,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Kua tiakina te papatohu"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Kua mukua pea te puna raraunga"
|
||||
|
||||
|
||||
@@ -3379,6 +3379,9 @@ msgstr "Veranderingen in de grafiek"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Grafiek kon niet worden aangemaakt."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "De grafiek kon niet worden bijgewerkt."
|
||||
|
||||
@@ -14912,6 +14915,11 @@ msgstr ""
|
||||
"Als een knooppunt is gekoppeld met meer dan één categorie, zal alleen de "
|
||||
"eerste worden gebruikt."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -15017,6 +15025,11 @@ msgstr "De landcode die Superset verwacht te vinden in de [land] kolom"
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Het dashboard is opgeslagen"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "De gegevensbron lijkt te zijn verwijderd"
|
||||
|
||||
|
||||
@@ -3384,6 +3384,9 @@ msgstr "Zmiany wykresu"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Nie można utworzyć wykresu."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Nie można zaktualizować wykresu."
|
||||
|
||||
@@ -15247,6 +15250,11 @@ msgstr ""
|
||||
"jest związany z więcej niż jedną kategorią, używana będzie tylko "
|
||||
"pierwsza."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -15348,6 +15356,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Pulpit nawigacyjny został zapisany."
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Źródło danych wydaje się zostało usunięte."
|
||||
|
||||
|
||||
@@ -4759,6 +4759,9 @@ msgstr "Modificado pela última vez"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Não foi possível gravar a sua query"
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Não foi possível gravar a sua query"
|
||||
|
||||
@@ -21413,6 +21416,11 @@ msgstr ""
|
||||
"estiver associado a mais do que uma categoria, apenas a primeira será "
|
||||
"utilizada."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -21548,6 +21556,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Dashboard gravado com sucesso."
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Esta origem de dados parece ter sido excluída"
|
||||
|
||||
|
||||
@@ -3334,6 +3334,9 @@ msgstr "Alterações no gráfico"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Não foi possível criar o gráfico."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Não foi possível atualizar o gráfico."
|
||||
|
||||
@@ -14976,6 +14979,11 @@ msgstr ""
|
||||
"estiver associado a mais do que uma categoria, apenas a primeira será "
|
||||
"utilizada."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -15080,6 +15088,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "O painel foi salvo"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "A Fonte de dados parece ter sido excluída"
|
||||
|
||||
|
||||
@@ -12046,9 +12046,19 @@ msgstr ""
|
||||
"Standardul codului de țară pe care Superset ar trebui să-l găsească în "
|
||||
"coloana [country]"
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Panoul de control a fost salvat"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Sursa de date pare să fi fost ștearsă"
|
||||
|
||||
|
||||
@@ -3110,6 +3110,9 @@ msgstr "Изменения диаграммы"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Не удалось создать диаграмму"
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Не удалось обновить диаграмму"
|
||||
|
||||
@@ -13527,6 +13530,11 @@ msgstr ""
|
||||
"Категория исходных вершин предназначена для задания цветов. Если вершина "
|
||||
"связана более, чем с одной категорией, только первая будет использована."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -13610,6 +13618,11 @@ msgstr "Код страны, который Superset ожидает найти
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Дашборд сохранен"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Похоже, источник данных был удален"
|
||||
|
||||
|
||||
@@ -3161,6 +3161,9 @@ msgstr "Zmeny grafu"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Graf sa nepodarilo vytvoriť."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Graf sa nepodarilo aktualizovať."
|
||||
|
||||
@@ -13863,6 +13866,11 @@ msgstr ""
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "Graf sa ešte načítava. Chvíľu počkajte a skúste to znova."
|
||||
|
||||
@@ -13949,6 +13957,11 @@ msgstr "Standard kódu zeme, ktorý by mel Superset očekávat ve stĺpci [zeme]
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Nástenka bola uložena"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Zdá se, že zdroj dat bol zmazaný"
|
||||
|
||||
|
||||
@@ -3300,6 +3300,9 @@ msgstr "Spremembe grafikona"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Grafikona ni mogoče ustvariti."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Grafikona ni mogoče posodobiti."
|
||||
|
||||
@@ -14552,6 +14555,11 @@ msgstr ""
|
||||
"Kategorija izvornih vozlišč, na podlagi katere je določena barva. Če je "
|
||||
"vozlišče povezano z več kot eno kategorijo, bo uporabljena samo prva."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -14648,6 +14656,11 @@ msgstr "Standard za oznake držav, ki bodo podane v stolpcu z državami"
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Nadzorna plošča je bila shranjena"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Zdi se, da je bil podatkovni vir izbrisan"
|
||||
|
||||
|
||||
@@ -16840,6 +16840,11 @@ msgstr ""
|
||||
"Категорија изворних чворова која се користи за додељивање боја. Ако је чвор "
|
||||
"повезан са више од једне категорије, користиће се само прва."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "Графикон се још учитава. Сачекајте тренутак и покушајте поново."
|
||||
|
||||
@@ -16933,6 +16938,11 @@ msgstr "Стандард кода земље који Superset треба да
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Контролна табла је сачувана"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Изгледа да је извор података обрисан"
|
||||
|
||||
@@ -16855,6 +16855,11 @@ msgstr ""
|
||||
"Kategorija izvornih čvorova koja se koristi za dodeljivanje boja. Ako je "
|
||||
"čvor povezan sa više od jedne kategorije, koristiće se samo prva."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "Grafikon se još učitava. Sačekajte trenutak i pokušajte ponovo."
|
||||
|
||||
@@ -16949,6 +16954,11 @@ msgstr ""
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Kontrolna tabla je sačuvana"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Izgleda da je izvor podataka obrisan"
|
||||
|
||||
@@ -5285,6 +5285,9 @@ msgstr "ไม่สามารถสร้างแผนภูมิได้
|
||||
# de, es, fa, fr, it, ja, ko, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, tr, uk,
|
||||
# zh, zh_TW]
|
||||
#, fuzzy
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "ไม่สามารถอัปเดตแผนภูมิได้"
|
||||
|
||||
@@ -24131,6 +24134,11 @@ msgstr ""
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "แผนภูมิยังคงโหลดอยู่ กรุณารอสักครู่แล้วลองอีกครั้ง"
|
||||
|
||||
@@ -24268,6 +24276,11 @@ msgstr "มาตรฐานรหัสประเทศที่ Superset
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "บันทึกแดชบอร์ดแล้ว"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
#, fuzzy
|
||||
|
||||
@@ -3261,6 +3261,9 @@ msgstr "Grafik değişiklikleri"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Grafik oluşturulamadı."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Grafik gencellenemedi."
|
||||
|
||||
@@ -14314,6 +14317,11 @@ msgstr ""
|
||||
"Renkler atamak için kullanılan kaynak düğümleri kategorisi. Eğer bir node"
|
||||
" bir kategoriden daha ilişkiliyse, sadece ilki kullanılacaktır."
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -14403,6 +14411,11 @@ msgstr "Superset'in [ülkede bulmayı beklemesi gereken ülke kodu standardı] s
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Panel kurtarıldı"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Veri kaynağı silinmiş gibi görünüyor"
|
||||
|
||||
|
||||
@@ -3109,6 +3109,9 @@ msgstr "Зміни діаграми"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "Не вдалося створити діаграму."
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "Не вдалося оновити діаграму."
|
||||
|
||||
@@ -13522,6 +13525,11 @@ msgstr ""
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "Графік ще завантажується. Зачекайте хвилину і спробуйте знову."
|
||||
|
||||
@@ -13607,6 +13615,11 @@ msgstr "Стандарт коду країни, який очікується в
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "Звіт збережено"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "Схоже, джерела даних вилучено"
|
||||
|
||||
|
||||
@@ -3370,6 +3370,9 @@ msgstr "图表变化"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "您的图表无法创建。"
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "您的图表无法更新。"
|
||||
|
||||
@@ -15096,6 +15099,11 @@ msgid ""
|
||||
"associated with more than one category, only the first will be used."
|
||||
msgstr "用于分配颜色的源节点类别。如果一个节点与多个类别关联,则只使用第一个类别"
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -15194,6 +15202,11 @@ msgstr "Superset 希望能够在 [国家] 栏中找到的 国家 / 地区 的标
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "该看板已成功保存。"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "数据源已经被删除"
|
||||
|
||||
|
||||
@@ -3349,6 +3349,9 @@ msgstr "圖表變化"
|
||||
msgid "Chart could not be created."
|
||||
msgstr "您的圖表無法創建。"
|
||||
|
||||
msgid "Chart could not be restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "Chart could not be updated."
|
||||
msgstr "您的圖表無法更新。"
|
||||
|
||||
@@ -15075,6 +15078,11 @@ msgid ""
|
||||
"with more than one category, only the first will be used."
|
||||
msgstr "用於分配颜色的源節點類别。如果一個節點與多個類别關聯,则只使用第一個類别"
|
||||
|
||||
msgid ""
|
||||
"The chart this report targets was deleted. Restore the chart, or update "
|
||||
"the report to point at an active chart."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
#, fuzzy
|
||||
@@ -15169,6 +15177,11 @@ msgstr "Superset 希望能够在 [國家] 欄中找到的 國家 / 地區 的標
|
||||
msgid "The dashboard has been saved"
|
||||
msgstr "該看板已成功保存。"
|
||||
|
||||
msgid ""
|
||||
"The dashboard this report targets was deleted. Restore the dashboard, or "
|
||||
"update the report to point at an active dashboard."
|
||||
msgstr ""
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "數據源已經被删除"
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ def rank(
|
||||
:return: a flat DataFrame
|
||||
"""
|
||||
if group_by:
|
||||
gb = df.groupby(group_by, group_keys=False)
|
||||
df["rank"] = gb.apply(lambda x: x[metric].rank(pct=True))
|
||||
df["rank"] = df.groupby(group_by)[metric].rank(pct=True)
|
||||
else:
|
||||
df["rank"] = df[metric].rank(pct=True)
|
||||
return df
|
||||
|
||||
+19
-1
@@ -65,6 +65,7 @@ from superset.themes.types import Theme, ThemeMode
|
||||
from superset.themes.utils import (
|
||||
is_valid_theme,
|
||||
)
|
||||
from superset.translations.utils import get_language_pack
|
||||
from superset.utils import core as utils, json
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
from superset.utils.version import get_version_metadata, visible_version_metadata
|
||||
@@ -576,7 +577,24 @@ def common_bootstrap_payload() -> dict[str, Any]:
|
||||
locale = get_locale()
|
||||
# Convert locale to string for proper cache key hashing
|
||||
locale_str = str(locale) if locale else None
|
||||
return cached_common_bootstrap_data(utils.get_user_id(), locale_str)
|
||||
payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str))
|
||||
# Inject the Jed language pack outside the per-user memoize so the cached
|
||||
# payload stays small and the pack is shared across users for the same
|
||||
# locale. The frontend uses it to configure the translator synchronously,
|
||||
# before any code-split chunk evaluates a module-level `const X = t('...')`
|
||||
# (upstream issue #35330).
|
||||
language = payload.get("locale")
|
||||
if language and language != "en":
|
||||
# Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC
|
||||
# (the workaround in #35330 does exactly that), otherwise load the
|
||||
# shared one. `get_language_pack` returns the empty English pack on a
|
||||
# miss, which is the right result (English) when no translation file
|
||||
# exists.
|
||||
pack = payload.get("language_pack") or get_language_pack(language)
|
||||
else:
|
||||
pack = None
|
||||
payload["language_pack"] = pack
|
||||
return payload
|
||||
|
||||
|
||||
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
|
||||
@@ -721,7 +721,7 @@ class TestFavoriteChartCommand(SupersetTestCase):
|
||||
def test_fave_unfave_chart_command_not_found(self):
|
||||
"""Test that faving / unfaving a non-existing chart raises an exception"""
|
||||
with self.client.application.test_request_context():
|
||||
example_chart_id = 1234
|
||||
example_chart_id = 0
|
||||
|
||||
with override_user(security_manager.find_user("admin")):
|
||||
with self.assertRaises(ChartNotFoundError): # noqa: PT027
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Integration tests for chart soft-delete and restore."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from superset import security_manager
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
|
||||
from superset.extensions import db
|
||||
from superset.models.dashboard import Dashboard, dashboard_slices
|
||||
from superset.models.slice import Slice
|
||||
from superset.reports.models import (
|
||||
ReportCreationMethod,
|
||||
ReportSchedule,
|
||||
ReportScheduleType,
|
||||
)
|
||||
from superset.utils import json
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.conftest import with_feature_flags
|
||||
from tests.integration_tests.constants import (
|
||||
ADMIN_USERNAME,
|
||||
ALPHA_USERNAME,
|
||||
GAMMA_USERNAME,
|
||||
)
|
||||
from tests.integration_tests.insert_chart_mixin import InsertChartMixin
|
||||
|
||||
|
||||
def _hard_delete_chart(chart_id: int) -> None:
|
||||
"""Hard-delete a chart row regardless of soft-delete state."""
|
||||
row = (
|
||||
db.session.query(Slice)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
|
||||
.filter(Slice.id == chart_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _hard_delete_dashboard_for_charts_test(dashboard_id: int) -> None:
|
||||
"""Hard-delete a dashboard row regardless of soft-delete state."""
|
||||
row = (
|
||||
db.session.query(Dashboard)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Dashboard}})
|
||||
.filter(Dashboard.id == dashboard_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestChartSoftDelete(InsertChartMixin, SupersetTestCase):
|
||||
"""Tests for chart soft-delete behaviour (T013, T016)."""
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_delete_chart_soft_deletes(self) -> None:
|
||||
"""DELETE /api/v1/chart/<pk> sets deleted_at instead of removing."""
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("soft_delete_test", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
rv = self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 200
|
||||
|
||||
# Row still exists in DB with deleted_at set
|
||||
row = (
|
||||
db.session.query(Slice)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
|
||||
.filter(Slice.id == chart_id)
|
||||
.one_or_none()
|
||||
)
|
||||
assert row is not None
|
||||
assert row.deleted_at is not None
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_soft_deleted_chart_excluded_from_get(self) -> None:
|
||||
"""GET /api/v1/chart/<pk> returns 404 for a soft-deleted chart."""
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("invisible_chart", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
rv = self.client.get(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 404
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_soft_deleted_chart_excluded_from_list(self) -> None:
|
||||
"""GET /api/v1/chart/ should not include soft-deleted charts."""
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("listed_then_deleted", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
rv = self.client.get("/api/v1/chart/")
|
||||
data = json.loads(rv.data)
|
||||
chart_ids = [c["id"] for c in data["result"]]
|
||||
assert chart_id not in chart_ids
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_soft_deleted_chart_included_in_list_when_requested(self) -> None:
|
||||
"""GET /api/v1/chart/ with chart_deleted_state=include returns deleted charts.""" # noqa: E501
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("listed_with_deleted", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
|
||||
rison_query = "(filters:!((col:id,opr:chart_deleted_state,value:include)))"
|
||||
rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
|
||||
assert rv.status_code == 200
|
||||
|
||||
data = json.loads(rv.data)
|
||||
deleted_row = next(
|
||||
(row for row in data["result"] if row["id"] == chart_id),
|
||||
None,
|
||||
)
|
||||
assert deleted_row is not None
|
||||
assert deleted_row["deleted_at"] is not None
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_only_filter_returns_only_soft_deleted_charts(self) -> None:
|
||||
"""chart_deleted_state=only excludes live rows and returns only deleted ones."""
|
||||
admin_id = self.get_user("admin").id
|
||||
live_chart = self.insert_chart("only_live", [admin_id], 1)
|
||||
deleted_chart = self.insert_chart("only_deleted", [admin_id], 1)
|
||||
live_id = live_chart.id
|
||||
deleted_id = deleted_chart.id
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
self.client.delete(f"/api/v1/chart/{deleted_id}")
|
||||
|
||||
rison_query = "(filters:!((col:id,opr:chart_deleted_state,value:only)))"
|
||||
rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
|
||||
assert rv.status_code == 200
|
||||
|
||||
data = json.loads(rv.data)
|
||||
returned_ids = {row["id"] for row in data["result"]}
|
||||
assert deleted_id in returned_ids
|
||||
assert live_id not in returned_ids
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(live_id)
|
||||
_hard_delete_chart(deleted_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_deleted_state_list_shows_owner_their_own_deleted(self) -> None:
|
||||
"""A non-admin owner can still enumerate their own soft-deleted charts.
|
||||
Deleted-state scoping mirrors the restore audience, so it must not lock
|
||||
owners out of their own trash."""
|
||||
alpha_id = self.get_user(ALPHA_USERNAME).id
|
||||
chart = self.insert_chart("sd_owner_chart", [alpha_id], 1)
|
||||
chart_id = chart.id
|
||||
|
||||
chart.deleted_at = datetime(2026, 1, 1, 12, 0, 0)
|
||||
db.session.commit()
|
||||
|
||||
self.login(ALPHA_USERNAME)
|
||||
rison_query = (
|
||||
"(filters:!((col:id,opr:chart_deleted_state,value:only)),page_size:200)"
|
||||
)
|
||||
rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
|
||||
assert rv.status_code == 200
|
||||
ids = [c["id"] for c in json.loads(rv.data)["result"]]
|
||||
assert chart_id in ids
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_deleted_state_list_hides_non_owned_from_read_access_user(self) -> None:
|
||||
"""A read-access non-owner must not enumerate a chart once it is
|
||||
soft-deleted.
|
||||
|
||||
Gamma is granted ``datasource_access`` to the chart's dataset, so
|
||||
``ChartFilter`` makes the chart visible to gamma while it is live. After
|
||||
soft-delete, the deleted-state list is scoped to the restore audience
|
||||
(owners/admins), so gamma — who could never restore it — must not see it
|
||||
via ``include`` or ``only``.
|
||||
"""
|
||||
admin_id = self.get_user(ADMIN_USERNAME).id
|
||||
chart = self.insert_chart("sd_acl_chart", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
|
||||
table = db.session.query(SqlaTable).get(1)
|
||||
gamma_role = security_manager.find_role("Gamma")
|
||||
pvm = security_manager.add_permission_view_menu("datasource_access", table.perm)
|
||||
gamma_role.permissions.append(pvm)
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
# Precondition: gamma can see the chart while it is live.
|
||||
self.login(GAMMA_USERNAME)
|
||||
rv = self.client.get("/api/v1/chart/?q=(page_size:200)")
|
||||
assert chart_id in [c["id"] for c in json.loads(rv.data)["result"]], (
|
||||
"precondition: gamma should see the live chart via datasource access"
|
||||
)
|
||||
|
||||
# Soft-delete directly (avoids a mid-test re-login to admin).
|
||||
reloaded = (
|
||||
db.session.query(Slice)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
|
||||
.filter(Slice.id == chart_id)
|
||||
.one()
|
||||
)
|
||||
reloaded.deleted_at = datetime(2026, 1, 1, 12, 0, 0)
|
||||
db.session.commit()
|
||||
|
||||
# Gamma must not see the soft-deleted chart in either mode.
|
||||
for value in ("include", "only"):
|
||||
rison_query = (
|
||||
f"(filters:!((col:id,opr:chart_deleted_state,value:{value})),"
|
||||
"page_size:200)"
|
||||
)
|
||||
rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
|
||||
assert rv.status_code == 200
|
||||
ids = [c["id"] for c in json.loads(rv.data)["result"]]
|
||||
assert chart_id not in ids, (
|
||||
"read-access non-owner must not enumerate a soft-deleted "
|
||||
f"chart via chart_deleted_state={value}"
|
||||
)
|
||||
finally:
|
||||
pvm = security_manager.find_permission_view_menu(
|
||||
"datasource_access", table.perm
|
||||
)
|
||||
if pvm:
|
||||
security_manager.del_permission_role(gamma_role, pvm)
|
||||
db.session.commit()
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_delete_already_soft_deleted_chart_returns_404(self) -> None:
|
||||
"""DELETE on an already soft-deleted chart returns 404 (FR-008)."""
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("double_delete_test", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
rv = self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 200
|
||||
rv = self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 404
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_delete_chart_blocked_when_report_references_it(self) -> None:
|
||||
"""DELETE /api/v1/chart/<id> returns 422 when a report references it.
|
||||
|
||||
Pins down the existing API protection in `DeleteChartCommand.validate()`:
|
||||
when *any* `report_schedule` row references the chart — active or
|
||||
paused; `ReportScheduleDAO.find_by_chart_ids` has no active-only
|
||||
predicate — the validation raises `ChartDeleteFailedReportsExistError`
|
||||
*before* `ChartDAO.delete()` is invoked, so no soft-delete routing
|
||||
happens. This is the contract soft-delete inherits from the
|
||||
pre-existing API; the validate/commit race and flag-toggle windows it
|
||||
cannot close are handled by the defensive guard in
|
||||
`commands/report/execute.py:_get_url`.
|
||||
"""
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("blocked_by_report_test", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
|
||||
report = ReportSchedule(
|
||||
type=ReportScheduleType.REPORT,
|
||||
name="blocking_report_for_chart_delete",
|
||||
description="Report that should block chart deletion",
|
||||
crontab="0 9 * * *",
|
||||
chart=chart,
|
||||
creation_method=ReportCreationMethod.ALERTS_REPORTS,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
report_id = report.id
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
rv = self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 422
|
||||
body = json.loads(rv.data)
|
||||
assert "associated alerts or reports" in body.get("message", "").lower() or (
|
||||
"associated" in body.get("message", "").lower()
|
||||
and "report" in body.get("message", "").lower()
|
||||
)
|
||||
assert "blocking_report_for_chart_delete" in body.get("message", "")
|
||||
|
||||
# Confirm the chart was NOT soft-deleted (deleted_at remains NULL).
|
||||
row = (
|
||||
db.session.query(Slice)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
|
||||
.filter(Slice.id == chart_id)
|
||||
.one()
|
||||
)
|
||||
assert row.deleted_at is None
|
||||
|
||||
# Cleanup
|
||||
db.session.delete(
|
||||
db.session.query(ReportSchedule)
|
||||
.filter(ReportSchedule.id == report_id)
|
||||
.one()
|
||||
)
|
||||
db.session.commit()
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
|
||||
class TestChartRestore(InsertChartMixin, SupersetTestCase):
|
||||
"""Tests for chart restore behaviour (T025)."""
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_restore_soft_deleted_chart(self) -> None:
|
||||
"""POST /api/v1/chart/<uuid>/restore makes the chart visible again."""
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("restore_test", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
chart_uuid = str(chart.uuid)
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
|
||||
assert rv.status_code == 200
|
||||
|
||||
rv = self.client.get(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 200
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_restore_failure_returns_422(self) -> None:
|
||||
"""A failure during restore surfaces as a clean 422 via the
|
||||
``ChartRestoreFailedError`` handler rather than an unhandled 500.
|
||||
|
||||
``RestoreChartCommand.run`` wraps the restore in ``@transaction``
|
||||
and rethrows ``ChartRestoreFailedError`` on any underlying
|
||||
SQLAlchemy error; this pins that the endpoint maps it to 422.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartRestoreFailedError,
|
||||
)
|
||||
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("restore_fail_test", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
chart_uuid = str(chart.uuid)
|
||||
self.login(ADMIN_USERNAME)
|
||||
self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
|
||||
with patch(
|
||||
"superset.commands.chart.restore.RestoreChartCommand.run",
|
||||
side_effect=ChartRestoreFailedError(),
|
||||
):
|
||||
rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
|
||||
assert rv.status_code == 422
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_restore_nonexistent_chart_returns_404(self) -> None:
|
||||
"""POST /api/v1/chart/<uuid>/restore returns 404 for unknown UUID."""
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.post(
|
||||
"/api/v1/chart/00000000-0000-0000-0000-000000000000/restore"
|
||||
)
|
||||
assert rv.status_code == 404
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_restore_active_chart_returns_404(self) -> None:
|
||||
"""POST /api/v1/chart/<uuid>/restore on active chart returns 404."""
|
||||
admin_id = self.get_user("admin").id
|
||||
chart = self.insert_chart("active_restore_test", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
chart_uuid = str(chart.uuid)
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
|
||||
assert rv.status_code == 404
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_restore_uses_can_write_permission(self) -> None:
|
||||
"""Non-admin owner with ``can_write_Chart`` can hit the restore
|
||||
endpoint.
|
||||
|
||||
Pins the permission contract: ``method_permission_name`` must map
|
||||
``restore`` to ``write`` so FAB's ``@protect`` resolves the gate to
|
||||
``can_write_Chart`` (which Alpha already carries), not the implicit
|
||||
fallback ``can_restore_Chart`` (which no standard role carries).
|
||||
|
||||
Without the mapping FAB defaults to ``can_<method>_<class>`` and
|
||||
every non-admin would get 403 here — admins bypass FAB permission
|
||||
checks entirely, so the admin-authed restore tests above don't
|
||||
exercise the mapping.
|
||||
"""
|
||||
alpha = self.get_user(ALPHA_USERNAME)
|
||||
chart = self.insert_chart("restore_perm_test", [alpha.id], 1)
|
||||
chart_id = chart.id
|
||||
chart_uuid = str(chart.uuid)
|
||||
|
||||
self.login(ALPHA_USERNAME)
|
||||
rv = self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 200, (
|
||||
f"Alpha owner soft-delete failed: {rv.status_code} {rv.data!r}"
|
||||
)
|
||||
|
||||
rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
|
||||
assert rv.status_code == 200, (
|
||||
f"Expected 200 from Alpha owner restore (can_write_Chart), got "
|
||||
f"{rv.status_code}: {rv.data!r}. If 403, "
|
||||
"method_permission_name is missing 'restore': 'write'."
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_restore_chart_reattaches_to_dashboards(self) -> None:
|
||||
"""Soft-deleting a chart preserves dashboard_slices junction rows;
|
||||
restore makes the chart reappear in its dashboards automatically.
|
||||
|
||||
This is the positive test that pins down the SIP's "no cascade"
|
||||
contract and the corrected commit ``feat(soft-delete): preserve
|
||||
dashboard_slices on chart soft-delete (MissingChart handles UI)``.
|
||||
Soft-delete leaves the junction intact so:
|
||||
|
||||
- dashboards continue to render the chart slot (frontend uses
|
||||
``MissingChart`` placeholder while the chart is hidden via the
|
||||
visibility filter)
|
||||
- on restore the chart is automatically a member of every
|
||||
dashboard it was a member of before, with no manual
|
||||
re-attachment step
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
admin_id = admin.id
|
||||
|
||||
chart = self.insert_chart("reattach_test_chart", [admin_id], 1)
|
||||
chart_id = chart.id
|
||||
chart_uuid = str(chart.uuid)
|
||||
|
||||
dashboard = Dashboard(
|
||||
dashboard_title="reattach_test_dashboard",
|
||||
slug="slug_reattach_test",
|
||||
owners=[admin],
|
||||
published=True,
|
||||
)
|
||||
dashboard.slices = [chart]
|
||||
db.session.add(dashboard)
|
||||
db.session.commit()
|
||||
dashboard_id = dashboard.id
|
||||
|
||||
# Sanity: the junction row exists
|
||||
junction_count = (
|
||||
db.session.query(dashboard_slices)
|
||||
.filter(
|
||||
dashboard_slices.c.dashboard_id == dashboard_id,
|
||||
dashboard_slices.c.slice_id == chart_id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
assert junction_count == 1, "junction row should exist after dashboard creation"
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
# Soft-delete the chart
|
||||
rv = self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 200
|
||||
|
||||
# The junction row is preserved (no cascade)
|
||||
junction_count_after_delete = (
|
||||
db.session.query(dashboard_slices)
|
||||
.filter(
|
||||
dashboard_slices.c.dashboard_id == dashboard_id,
|
||||
dashboard_slices.c.slice_id == chart_id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
assert junction_count_after_delete == 1, (
|
||||
"junction row should remain intact on chart soft-delete; "
|
||||
"MissingChart placeholder handles the UI gap"
|
||||
)
|
||||
|
||||
# The dashboard's loaded `slices` collection no longer includes the
|
||||
# soft-deleted chart (the global visibility filter applies to
|
||||
# relationship loads via `with_loader_criteria(..., include_aliases=True)`).
|
||||
db.session.expire_all()
|
||||
dashboard_after_delete = (
|
||||
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
||||
)
|
||||
assert chart_id not in [s.id for s in dashboard_after_delete.slices], (
|
||||
"soft-deleted chart should be filtered out of dashboard.slices "
|
||||
"by the visibility-filter listener"
|
||||
)
|
||||
|
||||
# Restore the chart
|
||||
rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
|
||||
assert rv.status_code == 200
|
||||
|
||||
# The chart automatically reappears in the dashboard — junction row
|
||||
# was preserved, so no manual reattach was needed.
|
||||
db.session.expire_all()
|
||||
dashboard_after_restore = (
|
||||
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
||||
)
|
||||
assert chart_id in [s.id for s in dashboard_after_restore.slices], (
|
||||
"restored chart should reappear in dashboard.slices automatically; "
|
||||
"the junction row was never removed by soft-delete"
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_dashboard_for_charts_test(dashboard_id)
|
||||
_hard_delete_chart(chart_id)
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_restore_chart_by_non_admin_owner(self) -> None:
|
||||
"""Non-admin owners can restore their own soft-deleted charts.
|
||||
|
||||
The unit-level restore command tests mock security; this
|
||||
integration test exercises the FAB security wiring end-to-end
|
||||
so a future change that breaks the owner check on a non-admin
|
||||
path can't slip through.
|
||||
"""
|
||||
alpha = self.get_user(ALPHA_USERNAME)
|
||||
alpha_id = alpha.id
|
||||
|
||||
chart = self.insert_chart("alpha_owned_chart", [alpha_id], 1)
|
||||
chart_id = chart.id
|
||||
chart_uuid = str(chart.uuid)
|
||||
|
||||
self.login(ALPHA_USERNAME)
|
||||
rv = self.client.delete(f"/api/v1/chart/{chart_id}")
|
||||
assert rv.status_code == 200
|
||||
|
||||
rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
|
||||
assert rv.status_code == 200, rv.data
|
||||
|
||||
db.session.expire_all()
|
||||
restored = db.session.query(Slice).filter(Slice.id == chart_id).one_or_none()
|
||||
assert restored is not None
|
||||
assert restored.deleted_at is None
|
||||
|
||||
# Cleanup
|
||||
_hard_delete_chart(chart_id)
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import copy
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -287,6 +288,186 @@ def test_import_existing_chart_with_permission(
|
||||
mock_can_access_chart.assert_called_once_with(slice)
|
||||
|
||||
|
||||
def _soft_delete_existing_chart(session: Session) -> int:
|
||||
"""Soft-delete the seeded chart (by fixture UUID) and return its original id.
|
||||
|
||||
Shared setup for the soft-delete import tests: locate the chart, stamp
|
||||
``deleted_at``, flush, and return the id so callers can assert the restore
|
||||
happened in place (same id).
|
||||
"""
|
||||
existing = (
|
||||
session.query(Slice).filter(Slice.uuid == chart_config["uuid"]).one_or_none()
|
||||
)
|
||||
assert existing is not None
|
||||
existing.deleted_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
session.flush()
|
||||
return existing.id
|
||||
|
||||
|
||||
def test_import_soft_deleted_chart_overwrite_restores_in_place(
|
||||
mocker: MockerFixture,
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Overwrite-importing a soft-deleted chart must restore the row in place,
|
||||
not hard-delete-and-replace. Otherwise out-of-archive references
|
||||
(dashboard_slices junctions, report.chart_id) would cascade away.
|
||||
"""
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
mocker.patch.object(security_manager, "can_access_chart", return_value=True)
|
||||
|
||||
original_id = _soft_delete_existing_chart(session_with_data)
|
||||
|
||||
admin = User(
|
||||
first_name="Alice",
|
||||
last_name="Doe",
|
||||
email="adoe@example.org",
|
||||
username="admin",
|
||||
roles=[Role(name="Admin")],
|
||||
)
|
||||
|
||||
config = copy.deepcopy(chart_config)
|
||||
config["datasource_id"] = 1
|
||||
config["datasource_type"] = "table"
|
||||
|
||||
with override_user(admin):
|
||||
chart = import_chart(config, overwrite=True)
|
||||
|
||||
assert chart.id == original_id
|
||||
assert chart.deleted_at is None
|
||||
|
||||
|
||||
def test_import_soft_deleted_chart_ignore_permissions_restores_in_place(
|
||||
mocker: MockerFixture,
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
The example loader path: ignore_permissions=True with no logged-in
|
||||
user. The if/elif structure must preserve config["id"] on the
|
||||
fallthrough overwrite path so the example loader can re-import over
|
||||
a soft-deleted match without colliding on the UUID unique index.
|
||||
"""
|
||||
original_id = _soft_delete_existing_chart(session_with_data)
|
||||
|
||||
config = copy.deepcopy(chart_config)
|
||||
config["datasource_id"] = 1
|
||||
config["datasource_type"] = "table"
|
||||
|
||||
chart = import_chart(config, overwrite=True, ignore_permissions=True)
|
||||
|
||||
assert chart.id == original_id
|
||||
assert chart.deleted_at is None
|
||||
|
||||
|
||||
def test_import_soft_deleted_chart_non_overwrite_restores_for_owner(
|
||||
mocker: MockerFixture,
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Non-overwrite re-import of a soft-deleted UUID is implicitly a
|
||||
restore-and-update: the user is bringing the chart back by uploading
|
||||
it again. The same ownership rule as the overwrite path applies, so
|
||||
an owner (or admin) succeeds without setting overwrite=True.
|
||||
"""
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
mocker.patch.object(security_manager, "can_access_chart", return_value=True)
|
||||
|
||||
original_id = _soft_delete_existing_chart(session_with_data)
|
||||
|
||||
admin = User(
|
||||
first_name="Alice",
|
||||
last_name="Doe",
|
||||
email="adoe@example.org",
|
||||
username="admin",
|
||||
roles=[Role(name="Admin")],
|
||||
)
|
||||
|
||||
config = copy.deepcopy(chart_config)
|
||||
config["datasource_id"] = 1
|
||||
config["datasource_type"] = "table"
|
||||
|
||||
with override_user(admin):
|
||||
chart = import_chart(config, overwrite=False)
|
||||
|
||||
assert chart.id == original_id
|
||||
assert chart.deleted_at is None
|
||||
|
||||
|
||||
def test_import_soft_deleted_chart_non_overwrite_raises_for_non_owner(
|
||||
mocker: MockerFixture,
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Non-overwrite re-import that would resurrect a soft-deleted chart
|
||||
must respect ownership: a non-owner without admin role cannot
|
||||
restore-via-import. Mirrors the explicit /restore endpoint's check.
|
||||
"""
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
mocker.patch.object(security_manager, "can_access_chart", return_value=True)
|
||||
|
||||
_soft_delete_existing_chart(session_with_data)
|
||||
|
||||
non_owner = User(
|
||||
first_name="Bob",
|
||||
last_name="Roe",
|
||||
email="bob@example.org",
|
||||
username="bob",
|
||||
roles=[Role(name="Gamma")],
|
||||
)
|
||||
|
||||
with override_user(non_owner):
|
||||
with pytest.raises(ImportFailedError) as excinfo:
|
||||
import_chart(chart_config, overwrite=False)
|
||||
assert "permissions to restore" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_import_soft_deleted_chart_raises_when_caller_lacks_can_write(
|
||||
mocker: MockerFixture,
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Case B: re-import of a soft-deleted UUID by a caller without
|
||||
can_write must raise, not silently return the soft-deleted row.
|
||||
|
||||
Real-world scenario: a user has can_write Dashboard but not
|
||||
can_write Chart, and they import a dashboard zip that references a
|
||||
soft-deleted chart. Silently returning the row would let the
|
||||
dashboard importer reattach to it via chart_ids[uuid] = existing.id
|
||||
and produce a dashboard with hidden (broken) charts.
|
||||
"""
|
||||
mocker.patch.object(security_manager, "can_access", return_value=False)
|
||||
|
||||
_soft_delete_existing_chart(session_with_data)
|
||||
|
||||
with pytest.raises(ImportFailedError) as excinfo:
|
||||
import_chart(chart_config, overwrite=False)
|
||||
assert "can_write" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_import_existing_active_chart_overwrite_without_can_write_returns_existing(
|
||||
mocker: MockerFixture,
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
An *active* (not soft-deleted) chart re-imported with overwrite=True by a
|
||||
caller without can_write must fall through to returning the existing row,
|
||||
not raise the restore error. Case B is keyed on ``is_soft_deleted``, so the
|
||||
fused ``needs_mutation`` condition must not pull active rows into the
|
||||
restore-without-permission branch (pre-soft-delete overwrite behaviour).
|
||||
"""
|
||||
mocker.patch.object(security_manager, "can_access", return_value=False)
|
||||
|
||||
existing = (
|
||||
session_with_data.query(Slice).filter(Slice.uuid == chart_config["uuid"]).one()
|
||||
)
|
||||
assert existing.deleted_at is None
|
||||
|
||||
result = import_chart(chart_config, overwrite=True)
|
||||
|
||||
assert result.id == existing.id
|
||||
assert result.deleted_at is None
|
||||
|
||||
|
||||
def test_import_tag_logic_for_charts(session_with_schema: Session):
|
||||
contents = {
|
||||
"tags.yaml": yaml.dump(
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Unit tests for RestoreChartCommand."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartNotFoundError,
|
||||
)
|
||||
from superset.commands.chart.restore import RestoreChartCommand
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def test_restore_chart_clears_deleted_at(app_context: None) -> None:
|
||||
"""RestoreChartCommand.run() restores a soft-deleted chart."""
|
||||
chart = MagicMock()
|
||||
chart.deleted_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
chart.id = 1
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.chart.ChartDAO.find_by_id", return_value=chart
|
||||
) as mock_find,
|
||||
patch("superset.commands.restore.security_manager") as mock_sec,
|
||||
):
|
||||
mock_sec.raise_for_ownership.return_value = None
|
||||
|
||||
cmd = RestoreChartCommand("1")
|
||||
cmd.run()
|
||||
|
||||
mock_find.assert_called_once()
|
||||
chart.restore.assert_called_once()
|
||||
|
||||
|
||||
def test_restore_chart_not_found_raises(app_context: None) -> None:
|
||||
"""RestoreChartCommand raises ChartNotFoundError for missing chart."""
|
||||
with patch("superset.daos.chart.ChartDAO.find_by_id", return_value=None):
|
||||
cmd = RestoreChartCommand("999")
|
||||
with pytest.raises(ChartNotFoundError):
|
||||
cmd.run()
|
||||
|
||||
|
||||
def test_restore_active_chart_raises_not_found(app_context: None) -> None:
|
||||
"""RestoreChartCommand raises ChartNotFoundError for non-deleted chart."""
|
||||
chart = MagicMock()
|
||||
chart.deleted_at = None # not soft-deleted
|
||||
|
||||
with patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart):
|
||||
cmd = RestoreChartCommand("1")
|
||||
with pytest.raises(ChartNotFoundError):
|
||||
cmd.run()
|
||||
|
||||
|
||||
def test_restore_chart_forbidden_raises(app_context: None) -> None:
|
||||
"""RestoreChartCommand raises ChartForbiddenError on permission check."""
|
||||
chart = MagicMock()
|
||||
chart.deleted_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
def raise_security(*args: object, **kwargs: object) -> None:
|
||||
raise SupersetSecurityException(MagicMock())
|
||||
|
||||
with (
|
||||
patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
|
||||
patch("superset.commands.restore.security_manager") as mock_sec,
|
||||
):
|
||||
mock_sec.raise_for_ownership = raise_security
|
||||
|
||||
cmd = RestoreChartCommand("1")
|
||||
with pytest.raises(ChartForbiddenError):
|
||||
cmd.run()
|
||||
@@ -2012,3 +2012,73 @@ def test_get_url_for_csv_uses_post_processed_type(
|
||||
f"CSV report URL must use type=post_processed so chart filters "
|
||||
f"(incl. time filters) are applied; got: {url}; see issue #25538"
|
||||
)
|
||||
|
||||
|
||||
def test_get_url_raises_when_target_chart_soft_deleted(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""A dangling chart reference must fail loudly, not fall through.
|
||||
|
||||
Soft delete removed the FK-level guarantee that a report's target chart
|
||||
exists: ``ReportSchedule.chart`` is a visibility-filtered relationship,
|
||||
so a chart soft-deleted after the report was created loads as ``None``
|
||||
while ``chart_id`` is still set. Pre-guard, ``_get_url`` silently fell
|
||||
through to the dashboard branch (``dashboard`` also ``None``) and failed
|
||||
opaquely; it must instead raise the dedicated, actionable error inside
|
||||
the state-machine envelope.
|
||||
"""
|
||||
from superset.commands.report.exceptions import (
|
||||
ReportScheduleTargetChartDeletedError,
|
||||
)
|
||||
|
||||
report_schedule = mocker.MagicMock()
|
||||
report_schedule.chart_id = 42
|
||||
report_schedule.chart = None
|
||||
|
||||
state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
|
||||
with pytest.raises(ReportScheduleTargetChartDeletedError):
|
||||
state._get_url()
|
||||
|
||||
|
||||
def test_get_url_raises_when_target_dashboard_soft_deleted(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""Symmetric twin of the chart-target guard: a dashboard report whose
|
||||
visibility-filtered ``dashboard`` relationship loads ``None`` while
|
||||
``dashboard_id`` is set must raise the dedicated error, not fall into
|
||||
``dashboard.id`` on ``None`` (an opaque ``AttributeError``)."""
|
||||
from superset.commands.report.exceptions import (
|
||||
ReportScheduleTargetDashboardDeletedError,
|
||||
)
|
||||
|
||||
report_schedule = mocker.MagicMock()
|
||||
report_schedule.chart_id = None
|
||||
report_schedule.chart = None
|
||||
report_schedule.dashboard_id = 7
|
||||
report_schedule.dashboard = None
|
||||
|
||||
state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
|
||||
with pytest.raises(ReportScheduleTargetDashboardDeletedError):
|
||||
state._get_url()
|
||||
|
||||
|
||||
def test_get_dashboard_urls_raises_when_target_dashboard_soft_deleted(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""``get_dashboard_urls`` is entered directly from the async command's
|
||||
permalink pre-commit (bypassing ``_get_url``), so it needs — and has —
|
||||
the same deleted-target guard."""
|
||||
from superset.commands.report.exceptions import (
|
||||
ReportScheduleTargetDashboardDeletedError,
|
||||
)
|
||||
|
||||
report_schedule = mocker.MagicMock()
|
||||
report_schedule.dashboard_id = 7
|
||||
report_schedule.dashboard = None
|
||||
|
||||
state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
|
||||
with pytest.raises(ReportScheduleTargetDashboardDeletedError):
|
||||
state.get_dashboard_urls()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from superset import db
|
||||
from superset.connectors.sqla.models import Database, SqlaTable
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from tests.unit_tests.conftest import with_feature_flags
|
||||
|
||||
|
||||
@with_feature_flags(SOFT_DELETE=True)
|
||||
def test_set_dash_metadata_preserves_soft_deleted_members(
|
||||
session: Session,
|
||||
) -> None:
|
||||
"""Saving a dashboard must not sever a soft-deleted member chart.
|
||||
|
||||
``set_dash_metadata`` rebuilds ``dashboard.slices`` wholesale from the
|
||||
incoming position data. The soft-delete visibility filter must be
|
||||
bypassed for the whole rebuild — the slice-resolution query AND the
|
||||
collection assignment:
|
||||
|
||||
- Filtered resolution would silently drop the trashed member from the
|
||||
new collection — deleting its ``dashboard_slices`` junction row
|
||||
(breaking the restore-reattach contract) and writing ``uuid: None``
|
||||
into its position slot.
|
||||
- A filtered *baseline* load (the unit of work lazy-loads the existing
|
||||
collection when diffing the assignment) would exclude the trashed
|
||||
member from the old collection, so the diff treats it as net-new and
|
||||
INSERTs a duplicate ``dashboard_slices`` row — an IntegrityError on
|
||||
the composite PK on every save of a dashboard containing a trashed
|
||||
chart.
|
||||
|
||||
The test reproduces the production shape: SOFT_DELETE enabled (the
|
||||
listener actually filters), the collection expired (as with the fresh
|
||||
``find_by_id`` load in the PUT flow), and a flush afterwards so the
|
||||
diff's SQL actually hits the composite-PK junction table. It fails on
|
||||
either a missing resolution bypass or a query-scoped-only bypass.
|
||||
"""
|
||||
Dashboard.metadata.create_all(session.get_bind())
|
||||
|
||||
dataset = SqlaTable(
|
||||
table_name="dash_meta_table",
|
||||
database=Database(database_name="dash_meta_db", sqlalchemy_uri="sqlite://"),
|
||||
)
|
||||
db.session.add(dataset)
|
||||
db.session.flush()
|
||||
|
||||
live_chart = Slice(
|
||||
slice_name="live_chart",
|
||||
datasource_id=dataset.id,
|
||||
datasource_type="table",
|
||||
)
|
||||
trashed_chart = Slice(
|
||||
slice_name="trashed_chart",
|
||||
datasource_id=dataset.id,
|
||||
datasource_type="table",
|
||||
deleted_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
dashboard = Dashboard(
|
||||
dashboard_title="meta_test_dash",
|
||||
slices=[live_chart, trashed_chart],
|
||||
published=True,
|
||||
)
|
||||
db.session.add_all([live_chart, trashed_chart, dashboard])
|
||||
db.session.flush()
|
||||
|
||||
# Production shape: the PUT flow loads a fresh Dashboard whose
|
||||
# ``slices`` collection is unloaded; expiring forces the baseline
|
||||
# reload through the visibility listener during the assignment.
|
||||
db.session.expire(dashboard, ["slices"])
|
||||
|
||||
positions: dict[str, dict[str, Any]] = {
|
||||
"CHART-live": {
|
||||
"type": "CHART",
|
||||
"id": "CHART-live",
|
||||
"children": [],
|
||||
"meta": {"chartId": live_chart.id, "width": 4, "height": 50},
|
||||
},
|
||||
"CHART-trashed": {
|
||||
"type": "CHART",
|
||||
"id": "CHART-trashed",
|
||||
"children": [],
|
||||
"meta": {"chartId": trashed_chart.id, "width": 4, "height": 50},
|
||||
},
|
||||
}
|
||||
|
||||
DashboardDAO.set_dash_metadata(dashboard, {"positions": positions})
|
||||
# Flush so the collection diff's SQL reaches the composite-PK junction
|
||||
# table — a duplicate INSERT fails here, not at assignment time.
|
||||
db.session.flush()
|
||||
|
||||
member_ids = {chart.id for chart in dashboard.slices}
|
||||
assert live_chart.id in member_ids
|
||||
assert trashed_chart.id in member_ids, (
|
||||
"soft-deleted member chart was severed from dashboard.slices; "
|
||||
"set_dash_metadata must bypass the visibility filter when "
|
||||
"resolving incoming chart ids"
|
||||
)
|
||||
# And the position slot kept its UUID rather than being nulled.
|
||||
assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
|
||||
@@ -187,7 +187,7 @@ async def test_duplicate_referencing_same_charts(
|
||||
# Response text is wrapped in LLM-context delimiters (prompt-injection
|
||||
# defense), matching the standard dashboard serializers.
|
||||
assert content["dashboard"]["dashboard_title"] == _wrapped("Staging Copy")
|
||||
assert "/superset/dashboard/2/" in content["dashboard_url"]
|
||||
assert "/dashboard/2/" in content["dashboard_url"]
|
||||
|
||||
# The copy data contract must mirror what the frontend "Save as" sends:
|
||||
# required json_metadata containing the source's metadata + positions.
|
||||
@@ -237,7 +237,7 @@ async def test_duplicate_with_duplicate_slices(
|
||||
assert content["error"] is None
|
||||
assert content["duplicated_slices"] is True
|
||||
assert content["dashboard"]["id"] == 3
|
||||
assert "/superset/dashboard/3/" in content["dashboard_url"]
|
||||
assert "/dashboard/3/" in content["dashboard_url"]
|
||||
|
||||
_, cmd_data = mock_copy_cmd_cls.call_args.args
|
||||
assert cmd_data["duplicate_slices"] is True
|
||||
@@ -449,7 +449,7 @@ async def test_refetch_failure_rolls_back_and_returns_minimal_response(
|
||||
assert content["error"] is None
|
||||
assert content["dashboard"]["id"] == 7
|
||||
assert content["dashboard"]["dashboard_title"] == _wrapped("Copy")
|
||||
assert "/superset/dashboard/7/" in content["dashboard_url"]
|
||||
assert "/dashboard/7/" in content["dashboard_url"]
|
||||
|
||||
|
||||
@patch("superset.commands.dashboard.copy.CopyDashboardCommand")
|
||||
|
||||
@@ -371,7 +371,7 @@ async def test_simple_grid_removal_prunes_empty_row(
|
||||
assert content["error"] is None
|
||||
assert content["permission_denied"] is False
|
||||
assert content["dashboard_url"] is not None
|
||||
assert "/superset/dashboard/1/" in content["dashboard_url"]
|
||||
assert "/dashboard/1/" in content["dashboard_url"]
|
||||
assert set(content["removed_layout_keys"]) == {"CHART-aaa", "ROW-1"}
|
||||
|
||||
dashboard_id, update_data = mock_update_cmd_cls.call_args.args
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Tests for migration ``7c4a8d09ca37_add_deleted_at_to_slices``.
|
||||
|
||||
Runs the migration's ``upgrade()`` and ``downgrade()`` against an
|
||||
in-memory SQLite engine with a real Alembic ``Operations`` context.
|
||||
The behaviour being pinned is the operator-facing contract documented
|
||||
in ``UPDATING.md``: ``downgrade()`` reverses the schema but does not
|
||||
hard-delete or otherwise mutate rows that were soft-deleted before
|
||||
the migration was reversed — those rows survive the downgrade and
|
||||
become visible to any code path that no longer applies the
|
||||
soft-delete visibility filter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
insert,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
select,
|
||||
String,
|
||||
Table,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
migration = import_module(
|
||||
"superset.migrations.versions."
|
||||
"2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices"
|
||||
)
|
||||
|
||||
TABLE_NAME: str = migration.TABLE_NAME # "slices"
|
||||
INDEX_NAME: str = migration.INDEX_NAME # "ix_slices_deleted_at"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
"""In-memory SQLite seeded with a minimal pre-migration ``slices`` table.
|
||||
|
||||
The real ``slices`` table has many columns; the migration only touches
|
||||
``deleted_at`` and its index, so only the columns that participate in
|
||||
the test are seeded.
|
||||
"""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
md = MetaData()
|
||||
Table(
|
||||
TABLE_NAME,
|
||||
md,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("slice_name", String(250), nullable=False),
|
||||
)
|
||||
md.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _columns(engine: Engine) -> set[str]:
|
||||
"""Current column names on the target ``slices`` table."""
|
||||
return {col["name"] for col in inspect(engine).get_columns(TABLE_NAME)}
|
||||
|
||||
|
||||
def _indexes(engine: Engine) -> set[str]:
|
||||
"""Current index names on the target ``slices`` table."""
|
||||
return {ix["name"] for ix in inspect(engine).get_indexes(TABLE_NAME)}
|
||||
|
||||
|
||||
def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
|
||||
"""upgrade() adds the nullable ``deleted_at`` column and its index."""
|
||||
with engine.connect() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
|
||||
assert "deleted_at" in _columns(engine), "upgrade() must add the deleted_at column"
|
||||
assert INDEX_NAME in _indexes(engine), (
|
||||
"upgrade() must create the supporting index on deleted_at"
|
||||
)
|
||||
|
||||
|
||||
def test_downgrade_drops_deleted_at_column_and_index(engine: Engine) -> None:
|
||||
"""downgrade() removes both schema artifacts (column and index)."""
|
||||
with engine.connect() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
migration.downgrade()
|
||||
|
||||
assert "deleted_at" not in _columns(engine), (
|
||||
"downgrade() must drop the deleted_at column"
|
||||
)
|
||||
assert INDEX_NAME not in _indexes(engine), (
|
||||
"downgrade() must drop the supporting index"
|
||||
)
|
||||
|
||||
|
||||
def test_downgrade_preserves_soft_deleted_row_data(engine: Engine) -> None:
|
||||
"""Pin the operator-facing contract from ``UPDATING.md``: rows that
|
||||
were soft-deleted before the migration is reversed survive the
|
||||
downgrade. The ``deleted_at`` column is gone, so those rows are
|
||||
indistinguishable from live rows to any code path that no longer
|
||||
applies the visibility filter — operators must decide on
|
||||
hard-delete, restore, or rename BEFORE downgrading. See the
|
||||
"Rollback note" in ``UPDATING.md``.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
|
||||
slices = Table(TABLE_NAME, MetaData(), autoload_with=conn)
|
||||
conn.execute(
|
||||
insert(slices).values(
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"slice_name": "live_chart",
|
||||
"deleted_at": None,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"slice_name": "archived_chart",
|
||||
"deleted_at": datetime(2026, 1, 1, 12, 0, 0),
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
migration.downgrade()
|
||||
|
||||
slices_after = Table(TABLE_NAME, MetaData(), autoload_with=conn)
|
||||
rows = conn.execute(
|
||||
select(slices_after).order_by(slices_after.c.id)
|
||||
).fetchall()
|
||||
|
||||
assert [(r.id, r.slice_name) for r in rows] == [
|
||||
(1, "live_chart"),
|
||||
(2, "archived_chart"),
|
||||
], (
|
||||
"downgrade() must not delete or mutate row data — soft-deleted "
|
||||
"rows become indistinguishable from live rows but they remain"
|
||||
)
|
||||
assert "deleted_at" not in {
|
||||
c["name"] for c in inspect(engine).get_columns(TABLE_NAME)
|
||||
}
|
||||
|
||||
|
||||
def test_upgrade_is_idempotent(engine: Engine) -> None:
|
||||
"""The migration helpers (``add_columns``, ``create_index``) are
|
||||
idempotent skip-if-exists; running ``upgrade()`` twice must not
|
||||
raise.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
|
||||
def test_downgrade_is_idempotent(engine: Engine) -> None:
|
||||
"""``drop_columns`` / ``drop_index`` are skip-if-not-exists; running
|
||||
``downgrade()`` twice must not raise.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
migration.downgrade()
|
||||
migration.downgrade()
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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 numpy as np
|
||||
|
||||
from superset.utils import pandas_postprocessing as pp
|
||||
from tests.unit_tests.fixtures.dataframes import categories_df
|
||||
|
||||
|
||||
def test_rank_should_rank():
|
||||
# Here we use np.isclose to avoid "false positives" in != tests
|
||||
# Plain
|
||||
_categories_df = categories_df.copy(deep=True)
|
||||
assert np.isclose(
|
||||
pp.rank(_categories_df, "asc_idx")["rank"],
|
||||
np.linspace(1.0 / 101.0, 1.0, 101),
|
||||
rtol=1e-8,
|
||||
).all()
|
||||
|
||||
# Grouped
|
||||
gb = pp.rank(_categories_df, "asc_idx", "dept").groupby("dept")
|
||||
res = gb.apply(
|
||||
lambda x: np.isclose(
|
||||
x.sort_values("rank")["rank"],
|
||||
np.linspace(1.0 / len(x), 1.0, len(x)),
|
||||
rtol=1e-8,
|
||||
).all()
|
||||
)
|
||||
assert res.all()
|
||||
|
||||
|
||||
def test_rank_single_cat():
|
||||
# Check that reducing the category to one value still holds valid results
|
||||
_categories_df = categories_df.copy(deep=True)
|
||||
|
||||
# This was raising up to 6.1.0, see https://github.com/apache/superset/issues/40709
|
||||
tmp_df = _categories_df[_categories_df["dept"] == "dept0"].reset_index(drop=True)
|
||||
pp.rank(tmp_df, "asc_idx", "dept")
|
||||
|
||||
assert tmp_df["rank"].min() == 1.0 / len(tmp_df)
|
||||
assert tmp_df["rank"].max() == 1.0
|
||||
@@ -49,7 +49,10 @@ def test_common_bootstrap_payload_converts_locale_to_string(
|
||||
|
||||
# Verify cached_common_bootstrap_data was called with string locale
|
||||
mock_cached.assert_called_once_with(1, "de_DE")
|
||||
assert result == {"test": "data"}
|
||||
# The wrapper copies the cached dict and injects `language_pack` after
|
||||
# the memoized call so the per-locale pack isn't duplicated per user.
|
||||
assert result["test"] == "data"
|
||||
assert "language_pack" in result
|
||||
|
||||
|
||||
@patch("superset.views.base.utils.get_user_id", return_value=1)
|
||||
|
||||
@@ -26,7 +26,7 @@ from flask_appbuilder.const import (
|
||||
AUTH_SAML,
|
||||
)
|
||||
|
||||
from superset.views.base import cached_common_bootstrap_data
|
||||
from superset.views.base import cached_common_bootstrap_data, common_bootstrap_payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -133,3 +133,73 @@ def test_recaptcha_shown_for_non_federated_auth(
|
||||
payload = _get_bootstrap()
|
||||
|
||||
assert payload["conf"]["RECAPTCHA_PUBLIC_KEY"] == "test-key"
|
||||
|
||||
|
||||
# --- language_pack injection --------------------------------------------
|
||||
#
|
||||
# The Jed pack is injected by `common_bootstrap_payload` (outside the
|
||||
# memoized `cached_common_bootstrap_data`) using the shared
|
||||
# `superset.translations.utils.get_language_pack`. Tests here cover the
|
||||
# wrapper to confirm the pack lands on the payload for non-English
|
||||
# locales and is None for English.
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_includes_language_pack_for_non_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""common.language_pack carries the shared utility's pack for non-en."""
|
||||
fake_pack = {"domain": "superset", "locale_data": {"superset": {}}}
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value={"locale": "fr"},
|
||||
),
|
||||
patch(
|
||||
"superset.views.base.get_language_pack",
|
||||
return_value=fake_pack,
|
||||
) as mock_get,
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
):
|
||||
payload = common_bootstrap_payload()
|
||||
|
||||
assert payload["language_pack"] == fake_pack
|
||||
mock_get.assert_called_once_with("fr")
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_skips_pack_for_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""English short-circuits: pack is None and the utility is not called."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value={"locale": "en"},
|
||||
),
|
||||
patch("superset.views.base.get_language_pack") as mock_get,
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="en"),
|
||||
):
|
||||
payload = common_bootstrap_payload()
|
||||
|
||||
assert payload["language_pack"] is None
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_does_not_mutate_memoized_dict(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""Injecting language_pack must not write back into the memoize cache."""
|
||||
cached: dict[str, Any] = {"locale": "fr"}
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value=cached,
|
||||
),
|
||||
patch("superset.views.base.get_language_pack", return_value={"x": 1}),
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
):
|
||||
common_bootstrap_payload()
|
||||
|
||||
assert "language_pack" not in cached
|
||||
|
||||
Reference in New Issue
Block a user