Compare commits

...
Author SHA1 Message Date
EvanandClaude Opus 4.8 985ebb796e test(databases): assert form inputs are enabled after SQLAlchemy switch
Strengthens the presence check to also confirm the form fields are
usable, per bito-code-review feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:26:41 -07:00
Claude Code b304f33a9b test(databases): stabilize flaky SQLAlchemy-form visibility assertion
The form mounts inside DatabaseModal's animated tab pane, and
rc-motion's animation state in jsdom is nondeterministic: toBeVisible
intermittently times out (observed on unrelated PRs' jest shards) even
though the form is rendered. Assert on document presence instead, which
is what the tab-switch regression check actually needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:17:11 -07:00
marunandEvan Rusackas 83d93b8b42 fix(view-query): Add ownState parameter to ViewQueryModal for query consistency (#35208)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-06 11:16:55 -07:00
Elizabeth Thompson 06f421ed4a fix(a11y): add aria-label to remove-sheet icon button in Google Sheets catalog (#41796) 2026-07-06 10:38:42 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dc671ae44a chore(deps-dev): bump eslint from 10.5.0 to 10.6.0 in /superset-frontend (#41792)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:38:16 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c49d01223c chore(deps-dev): bump webpack from 5.108.0 to 5.108.2 in /docs (#41786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:37:37 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 69f052985f chore(deps-dev): bump prettier from 3.8.4 to 3.9.3 in /superset-frontend (#41784)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:37:23 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f4cc871f8c chore(deps): bump js-yaml from 5.1.0 to 5.2.0 in /docs (#41783)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:37:09 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2f11240339 chore(deps): bump query-string from 9.4.0 to 9.4.1 in /superset-frontend (#41782)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:36:55 -07:00
a7a051777c feat(charts): soft-delete and restore (#40129)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 10:07:04 -07:00
Enzo Martellucci b4218136be feat(ag-grid): support Show summary in raw records mode (#41754) 2026-07-06 17:58:02 +02:00
Joe LiandClaude Opus 4.8 fd8d7009a5 chore(mcp): drop legacy /superset prefix from dashboard URLs (#41758)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:57:08 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> beff76cb4d chore(deps): bump antd from 6.4.5 to 6.5.0 in /docs (#41788)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:27:14 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d6b31376a5 chore(deps-dev): bump prettier from 3.8.4 to 3.9.1 in /docs (#41789)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:21:11 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1575b83f96 chore(deps): bump react-arborist from 3.10.5 to 3.12.0 in /superset-frontend (#41793)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:19:50 +07:00
83826839a0 fix(i18n): inject language pack into bootstrap to translate code-split chunks (#39357)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-05 21:15:40 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Evan Rusackas
a8ab5f973d chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.62.0 in /superset-frontend (#41577)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-05 15:55:31 -07:00
Elizabeth ThompsonandĐỗ Trọng Hải 7cdfe3ffe7 fix(a11y): add aria-label to CRON schedule input (#41769)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-07-05 15:01:00 -07:00
9a24d42d65 fix(chart): Fix heatmap charts when filtering down to one row/colum (#40738)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-05 14:24:33 -07:00
3303d4e09e chore(deps): bump lodash-es to ^4.18.1 across all frontend workspaces (#41774)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-07-05 21:28:58 +07:00
96 changed files with 3404 additions and 651 deletions
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+60 -394
View File
@@ -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",
+6 -6
View File
@@ -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}
@@ -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([
+45 -26
View File
@@ -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
View File
@@ -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
+38
View File
@@ -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))
+10
View File
@@ -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")
+98 -11
View File
@@ -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
+39
View File
@@ -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
+14
View File
@@ -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.")
+36
View File
@@ -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 (
+25 -5
View File
@@ -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}
+2 -2
View File
@@ -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",
@@ -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")
+6 -2
View File
@@ -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"""
+30
View File
@@ -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 dune 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"
+13
View File
@@ -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 "數據源已經被删除"
+1 -2
View File
@@ -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
View File
@@ -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()
+119
View File
@@ -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
+4 -1
View File
@@ -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)
+71 -1
View File
@@ -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