Compare commits

...

23 Commits

Author SHA1 Message Date
Elizabeth Thompson
482bef1507 fix(playwright): Use actual viewport height for tiled screenshot scroll calculations
The configured SCREENSHOT_TILED_VIEWPORT_HEIGHT (default 2000px) was larger than
Playwright's actual browser viewport (~768-1200px), causing scroll increments to
skip content between tiles. Now we fetch the actual viewport height and use
min(configured, actual) for scroll calculations to ensure complete content coverage.

Added regression test to verify tiles properly cover all content when configured
viewport exceeds actual viewport size.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:38:24 -07:00
Elizabeth Thompson
20c04a4663 fix(playwright): Simplify tiled screenshot clip calculation to fix multi-tile captures
Fixes issue where Playwright tiled screenshots only captured the first frame
and failed to capture subsequent tiles on large dashboards (50+ charts).

**Problem:**
The previous fix prevented errors but used overly complex tile_content_height
calculations that caused incorrect clip dimensions for tiles after the first one.
Customer testing revealed screenshots would not capture content past the first tile.

**Solution:**
Simplified the clip height calculation by:
- Removing tile_content_height from the clip height logic
- Directly calculating visible portion of element in viewport
- Maintaining proper handling for elements scrolled above viewport

After scrolling to position each tile, we now simply capture what's visible
of the element rather than trying to match a calculated content height.

**Testing:**
- All 21 unit tests pass including edge cases
- Negative element positions (scrolled above viewport)
- Elements extending beyond viewport boundaries
- Elements completely out of view

Based on customer feedback from Brandon Sovran who confirmed this approach
successfully captures massive pages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 16:43:32 -07:00
Elizabeth Thompson
782f5eab16 fix(playwright): Fix tiled screenshot clip bounds validation
Fixes issue where Playwright tiled screenshots fail with "Clipped area is either empty or outside the resulting image" error on large dashboards (50+ charts).

The problem occurred when:
- Dashboard elements scroll above viewport (negative Y coordinates)
- Clip regions extend beyond viewport boundaries
- Element dimensions result in invalid clip calculations

Changes:
- Add viewport dimension tracking to clip calculations
- Clamp coordinates to viewport bounds (prevent negative x/y)
- Calculate visible portions for partially scrolled elements
- Validate clip dimensions before screenshot (skip invalid tiles)
- Add comprehensive test coverage for edge cases

Tested with 21 unit tests including:
- Negative element positions
- Elements beyond viewport bounds
- Invalid clip dimensions
- Zero-width elements
- Elements completely scrolled out of view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 14:14:51 -07:00
Richard Fogaca Nienkotter
1234533c67 fix: edit dataset modal visual fixes (#35799) 2025-10-22 22:43:13 +03:00
Mehmet Salih Yavuz
7f0c0aea94 fix(ThemeController): replace fetch with SupersetClient for proper auth (#35794) 2025-10-22 19:54:28 +03:00
Fabian Halkivaha
d9dcbb68b7 chore(docs): use native docusauros admonition notation (#35791) 2025-10-22 11:39:52 -04:00
Mehmet Salih Yavuz
98fba1eefe fix(security): Add active property to guest user (#35454) 2025-10-22 12:51:19 +03:00
Geidō
bad03b1e76 fix(Actions): Improper spacing (#35724) 2025-10-21 20:10:00 +02:00
Geidō
fcfafebb29 fix(Themes): Local label inconsistent behaviors (#35663) 2025-10-21 20:09:33 +02:00
dependabot[bot]
47e82b02ed chore(deps-dev): bump ts-jest from 29.4.0 to 29.4.5 in /superset-frontend (#35732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 10:54:09 -07:00
dependabot[bot]
a463d66c80 chore(deps-dev): bump typescript-eslint from 8.46.1 to 8.46.2 in /superset-websocket (#35757)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 10:53:02 -07:00
Michael S. Molina
337da13ba7 fix: Changes ResultSet to include sqlEditorImmutableId when fetching results (#35773) 2025-10-21 14:24:39 -03:00
dependabot[bot]
4a3453999a chore(deps-dev): bump eslint from 9.37.0 to 9.38.0 in /docs (#35727)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 10:21:37 -07:00
SkinnyPigeon
58758de93d feat(reports): allow custom na values (#35481)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
2025-10-21 13:05:57 -04:00
dependabot[bot]
b4a8acc584 chore(deps-dev): bump @babel/compat-data from 7.28.0 to 7.28.4 in /superset-frontend (#35730)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:57:51 -07:00
dependabot[bot]
08f89690e9 chore(deps-dev): bump html-webpack-plugin from 5.6.3 to 5.6.4 in /superset-frontend (#35755)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:56:55 -07:00
dependabot[bot]
f02899d38d chore(deps-dev): bump @types/node from 24.8.1 to 24.9.1 in /superset-websocket (#35761)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:56:13 -07:00
dependabot[bot]
86583f1121 chore(deps-dev): bump @typescript-eslint/parser from 8.46.1 to 8.46.2 in /superset-websocket (#35759)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:55:05 -07:00
dependabot[bot]
26cbd71099 chore(deps-dev): bump prettier-plugin-packagejson from 2.5.8 to 2.5.19 in /superset-frontend (#35760)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:49:49 -07:00
dependabot[bot]
500ce7a02a chore(deps): bump ace-builds from 1.43.3 to 1.43.4 in /superset-frontend (#35763)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:48:51 -07:00
dependabot[bot]
6d8ceed10e chore(deps-dev): bump typescript-eslint from 8.46.1 to 8.46.2 in /docs (#35764)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:46:51 -07:00
dependabot[bot]
68d65f727f chore(deps): bump antd from 5.27.5 to 5.27.6 in /docs (#35765)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 09:46:10 -07:00
Yuvraj Singh Chauhan
f165785003 docs: update links in CONTRIBUTING.md to point to the correct Developer Portal URLs (#35723) 2025-10-20 17:03:43 -07:00
32 changed files with 1572 additions and 568 deletions

View File

@@ -25,14 +25,14 @@ little bit helps, and credit will always be given.
All developer and contribution documentation has moved to the Apache Superset Developer Portal:
**[📚 View the Developer Portal →](https://superset.apache.org/docs/developer-portal/)**
**[📚 View the Developer Portal →](https://superset.apache.org/developer_portal/)**
The Developer Portal includes comprehensive guides for:
- [Contributing Overview](https://superset.apache.org/docs/developer-portal/contributing/overview)
- [Development Setup](https://superset.apache.org/docs/developer-portal/contributing/development-setup)
- [Submitting Pull Requests](https://superset.apache.org/docs/developer-portal/contributing/submitting-pr)
- [Contribution Guidelines](https://superset.apache.org/docs/developer-portal/contributing/guidelines)
- [Code Review Process](https://superset.apache.org/docs/developer-portal/contributing/code-review)
- [Development How-tos](https://superset.apache.org/docs/developer-portal/contributing/howtos)
- [Contributing Overview](https://superset.apache.org/developer_portal/contributing/overview)
- [Development Setup](https://superset.apache.org/developer_portal/contributing/development-setup)
- [Submitting Pull Requests](https://superset.apache.org/developer_portal/contributing/submitting-pr)
- [Contribution Guidelines](https://superset.apache.org/developer_portal/contributing/guidelines)
- [Code Review Process](https://superset.apache.org/developer_portal/contributing/code-review)
- [Development How-tos](https://superset.apache.org/developer_portal/contributing/howtos)
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_portal).

View File

@@ -12,11 +12,13 @@ version: 1
SQL Lab and Explore supports [Jinja templating](https://jinja.palletsprojects.com/en/2.11.x/) in queries.
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in `superset_config.py`.
> #### ⚠️ Security Warning
>
> While powerful, this feature executes template code on the server. Within the Superset security model, this is **intended functionality**, as users with permissions to edit charts and virtual datasets are considered **trusted users**.
>
> If you grant these permissions to untrusted users, this feature can be exploited as a **Server-Side Template Injection (SSTI)** vulnerability. Do not enable `ENABLE_TEMPLATE_PROCESSING` unless you fully understand and accept the associated security risks.
:::warning[Security Warning]
While powerful, this feature executes template code on the server. Within the Superset security model, this is **intended functionality**, as users with permissions to edit charts and virtual datasets are considered **trusted users**.
If you grant these permissions to untrusted users, this feature can be exploited as a **Server-Side Template Injection (SSTI)** vulnerability. Do not enable `ENABLE_TEMPLATE_PROCESSING` unless you fully understand and accept the associated security risks.
:::
When templating is enabled, python code can be embedded in virtual datasets and
in Custom SQL in the filter and metric controls in Explore. By default, the following variables are

View File

@@ -49,7 +49,7 @@
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@superset-ui/core": "^0.20.4",
"antd": "^5.27.5",
"antd": "^5.27.6",
"caniuse-lite": "^1.0.30001751",
"docusaurus-plugin-less": "^2.0.2",
"json-bigint": "^1.0.0",
@@ -74,14 +74,14 @@
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.46.0",
"eslint": "^9.37.0",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.1",
"typescript-eslint": "^8.46.2",
"webpack": "^5.102.1"
},
"browserslist": {

View File

@@ -2428,19 +2428,19 @@
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
"@eslint/config-array@^0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.0.tgz#abdbcbd16b124c638081766392a4d6b509f72636"
integrity sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==
"@eslint/config-array@^0.21.1":
version "0.21.1"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713"
integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==
dependencies:
"@eslint/object-schema" "^2.1.6"
"@eslint/object-schema" "^2.1.7"
debug "^4.3.1"
minimatch "^3.1.2"
"@eslint/config-helpers@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.0.tgz#e9f94ba3b5b875e32205cb83fece18e64486e9e6"
integrity sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==
"@eslint/config-helpers@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.1.tgz#7d173a1a35fe256f0989a0fdd8d911ebbbf50037"
integrity sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==
dependencies:
"@eslint/core" "^0.16.0"
@@ -2466,20 +2466,15 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.37.0":
version "9.37.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b"
integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==
"@eslint/js@^9.38.0":
"@eslint/js@9.38.0", "@eslint/js@^9.38.0":
version "9.38.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.38.0.tgz#f7aa9c7577577f53302c1d795643589d7709ebd1"
integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==
"@eslint/object-schema@^2.1.6":
version "2.1.6"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
"@eslint/object-schema@^2.1.7":
version "2.1.7"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad"
integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==
"@eslint/plugin-kit@^0.4.0":
version "0.4.0"
@@ -4340,79 +4335,79 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.46.1", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz#20876354024140aabc8b400bc95735fdcade17d5"
integrity sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==
"@typescript-eslint/eslint-plugin@8.46.2", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
integrity sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.46.1"
"@typescript-eslint/type-utils" "8.46.1"
"@typescript-eslint/utils" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/type-utils" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.46.1", "@typescript-eslint/parser@^8.46.0":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.1.tgz#81751f46800fc6b01ce1a72760cd17f06e7f395b"
integrity sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==
"@typescript-eslint/parser@8.46.2", "@typescript-eslint/parser@^8.46.0":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf"
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
dependencies:
"@typescript-eslint/scope-manager" "8.46.1"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
debug "^4.3.4"
"@typescript-eslint/project-service@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.1.tgz#07be0e6f27fa90a17d8e5f6996ee02329c9a8c2e"
integrity sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==
"@typescript-eslint/project-service@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608"
integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.46.1"
"@typescript-eslint/types" "^8.46.1"
"@typescript-eslint/tsconfig-utils" "^8.46.2"
"@typescript-eslint/types" "^8.46.2"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz#590dd2e65e95af646bdaf50adeae9af39e25e8c1"
integrity sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==
"@typescript-eslint/scope-manager@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88"
integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==
dependencies:
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
"@typescript-eslint/tsconfig-utils@8.46.1", "@typescript-eslint/tsconfig-utils@^8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz#24405888560175c6c209c39df11ac06a2efef9d7"
integrity sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==
"@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c"
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
"@typescript-eslint/type-utils@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz#14d4307dd6045f6b48a888cde1513d6ec305537f"
integrity sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==
"@typescript-eslint/type-utils@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz#802d027864e6fb752e65425ed09f3e089fb4d384"
integrity sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==
dependencies:
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/utils" "8.46.1"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.46.1", "@typescript-eslint/types@^8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.1.tgz#4c5479538ec10b5508b8e982e172911c987446d8"
integrity sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==
"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763"
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
"@typescript-eslint/typescript-estree@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz#1c146573b942ebe609c156c217ceafdc7a88e6ed"
integrity sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==
"@typescript-eslint/typescript-estree@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08"
integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==
dependencies:
"@typescript-eslint/project-service" "8.46.1"
"@typescript-eslint/tsconfig-utils" "8.46.1"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
"@typescript-eslint/project-service" "8.46.2"
"@typescript-eslint/tsconfig-utils" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -4420,22 +4415,22 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.1.tgz#c572184d9227d66b10a954b90249a20c48b22452"
integrity sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==
"@typescript-eslint/utils@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.2.tgz#b313d33d67f9918583af205bd7bcebf20f231732"
integrity sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.46.1"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/visitor-keys@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz#da35f1d58ec407419d68847cfd358b32746ac315"
integrity sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==
"@typescript-eslint/visitor-keys@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738"
integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==
dependencies:
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/types" "8.46.2"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.0.0":
@@ -4750,10 +4745,10 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.27.5:
version "5.27.5"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.5.tgz#978b265c722b9229e7dcc2fcddc5f5445af9bdf0"
integrity sha512-Ehd9mqtHvJ1clon1yJ/1BTV6eX/3SH2YXZZPTHUk8XdzXFwUioI+Lht47s+MaHIUBY77RnZrmtKwwR+VVu0l7A==
antd@^5.27.6:
version "5.27.6"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.6.tgz#6b7c7a87b5c696395d2aab2fdbd8409a342813e1"
integrity sha512-70HrjVbzDXvtiUQ5MP1XdNudr/wGAk9Ivaemk6f36yrAeJurJSmZ8KngOIilolLRHdGuNc6/Vk+4T1OZpSjpag==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -7001,24 +6996,23 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
eslint@^9.37.0:
version "9.37.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.37.0.tgz#ac0222127f76b09c0db63036f4fe289562072d74"
integrity sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==
eslint@^9.38.0:
version "9.38.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.38.0.tgz#3957d2af804e5cf6cc503c618f60acc71acb2e7e"
integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.1"
"@eslint/config-array" "^0.21.0"
"@eslint/config-helpers" "^0.4.0"
"@eslint/config-array" "^0.21.1"
"@eslint/config-helpers" "^0.4.1"
"@eslint/core" "^0.16.0"
"@eslint/eslintrc" "^3.3.1"
"@eslint/js" "9.37.0"
"@eslint/js" "9.38.0"
"@eslint/plugin-kit" "^0.4.0"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
"@types/estree" "^1.0.6"
"@types/json-schema" "^7.0.15"
ajv "^6.12.4"
chalk "^4.0.0"
cross-spawn "^7.0.6"
@@ -13594,15 +13588,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.46.1:
version "8.46.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.1.tgz#baeb322ee83ca566a8cf1f6403847694a3acd44a"
integrity sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==
typescript-eslint@^8.46.2:
version "8.46.2"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.2.tgz#da1adec683ba93a1b6c3850a4efb0922ffbc627d"
integrity sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==
dependencies:
"@typescript-eslint/eslint-plugin" "8.46.1"
"@typescript-eslint/parser" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/utils" "8.46.1"
"@typescript-eslint/eslint-plugin" "8.46.2"
"@typescript-eslint/parser" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
typescript@~5.9.3:
version "5.9.3"

View File

@@ -140,7 +140,7 @@
"devDependencies": {
"@applitools/eyes-storybook": "^3.60.0",
"@babel/cli": "^7.28.3",
"@babel/compat-data": "^7.28.0",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.28.3",
"@babel/eslint-parser": "^7.28.4",
"@babel/node": "^7.22.6",
@@ -239,7 +239,7 @@
"fetch-mock": "^11.1.5",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"history": "^5.3.0",
"html-webpack-plugin": "^5.6.3",
"html-webpack-plugin": "^5.6.4",
"imports-loader": "^5.0.0",
"jest": "^30.0.2",
"jest-environment-jsdom": "^29.7.0",
@@ -251,7 +251,7 @@
"open-cli": "^8.0.0",
"po2json": "^0.4.5",
"prettier": "3.6.2",
"prettier-plugin-packagejson": "^2.5.3",
"prettier-plugin-packagejson": "^2.5.19",
"process": "^0.11.10",
"react-resizable": "^3.0.5",
"redux-mock-store": "^1.5.4",
@@ -262,7 +262,7 @@
"storybook": "8.1.11",
"style-loader": "^4.0.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.0",
"ts-jest": "^29.4.5",
"ts-loader": "^9.5.1",
"tscw-config": "^1.1.2",
"tsx": "^4.20.3",
@@ -1150,9 +1150,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
"integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10589,16 +10589,16 @@
}
},
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@playwright/test": {
@@ -19152,9 +19152,9 @@
}
},
"node_modules/ace-builds": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.1.tgz",
"integrity": "sha512-n9/n+zBhbbkEJjU0FJ4wWAZBDl5G8WYzg4+uIjSER/U3wSSSSVo52W4sco4Jryg11JAJvorExxMr3GDINqtjdA==",
"version": "1.43.4",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.4.tgz",
"integrity": "sha512-8hAxVfo2ImICd69BWlZwZlxe9rxDGDjuUhh+WeWgGDvfBCE+r3lkynkQvIovDz4jcMi8O7bsEaFygaDT+h9sBA==",
"license": "BSD-3-Clause",
"peer": true
},
@@ -24364,13 +24364,16 @@
}
},
"node_modules/detect-indent": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.1.tgz",
"integrity": "sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz",
"integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/detect-newline": {
@@ -26385,35 +26388,6 @@
}
}
},
"node_modules/eslint-plugin-prettier/node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/eslint-plugin-prettier/node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -29567,9 +29541,9 @@
}
},
"node_modules/git-hooks-list": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-3.1.0.tgz",
"integrity": "sha512-LF8VeHeR7v+wAbXqfgRlTSX/1BJR9Q1vEMR8JAz1cEg6GX07+zyj3sAdDvYjj/xnlIfVuGgj4qBei1K3hKH+PA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.1.1.tgz",
"integrity": "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -30912,9 +30886,9 @@
}
},
"node_modules/html-webpack-plugin": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz",
"integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==",
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz",
"integrity": "sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -35770,19 +35744,6 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-snapshot/node_modules/@pkgr/core": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz",
"integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/jest-snapshot/node_modules/@sinclair/typebox": {
"version": "0.34.37",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz",
@@ -35975,22 +35936,6 @@
"node": ">=8"
}
},
"node_modules/jest-snapshot/node_modules/synckit": {
"version": "0.11.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz",
"integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.4"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/jest-util": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
@@ -46904,14 +46849,14 @@
}
},
"node_modules/prettier-plugin-packagejson": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.5.8.tgz",
"integrity": "sha512-BaGOF63I0IJZoudxpuQe17naV93BRtK8b3byWktkJReKEMX9CC4qdGUzThPDVO/AUhPzlqDiAXbp18U6X8wLKA==",
"version": "2.5.19",
"resolved": "https://registry.npmjs.org/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.5.19.tgz",
"integrity": "sha512-Qsqp4+jsZbKMpEGZB1UP1pxeAT8sCzne2IwnKkr+QhUe665EXUo3BAvTf1kAPCqyMv9kg3ZmO0+7eOni/C6Uag==",
"dev": true,
"license": "MIT",
"dependencies": {
"sort-package-json": "2.14.0",
"synckit": "0.9.2"
"sort-package-json": "3.4.0",
"synckit": "0.11.11"
},
"peerDependencies": {
"prettier": ">= 1.16.0"
@@ -53042,23 +52987,25 @@
"license": "MIT"
},
"node_modules/sort-package-json": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-2.14.0.tgz",
"integrity": "sha512-xBRdmMjFB/KW3l51mP31dhlaiFmqkHLfWTfZAno8prb/wbDxwBPWFpxB16GZbiPbYr3wL41H8Kx22QIDWRe8WQ==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.4.0.tgz",
"integrity": "sha512-97oFRRMM2/Js4oEA9LJhjyMlde+2ewpZQf53pgue27UkbEXfHJnDzHlUxQ/DWUkzqmp7DFwJp8D+wi/TYeQhpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"detect-indent": "^7.0.1",
"detect-newline": "^4.0.0",
"get-stdin": "^9.0.0",
"git-hooks-list": "^3.0.0",
"detect-newline": "^4.0.1",
"git-hooks-list": "^4.0.0",
"is-plain-obj": "^4.1.0",
"semver": "^7.6.0",
"semver": "^7.7.1",
"sort-object-keys": "^1.1.3",
"tinyglobby": "^0.2.9"
"tinyglobby": "^0.2.12"
},
"bin": {
"sort-package-json": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/sort-package-json/node_modules/detect-newline": {
@@ -53087,6 +53034,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sort-package-json/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-list-map": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@@ -54396,20 +54356,19 @@
"license": "MIT"
},
"node_modules/synckit": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz",
"integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==",
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.1.0",
"tslib": "^2.6.2"
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tapable": {
@@ -55400,19 +55359,19 @@
}
},
"node_modules/ts-jest": {
"version": "29.4.0",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz",
"integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==",
"version": "29.4.5",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz",
"integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"bs-logger": "^0.2.6",
"ejs": "^3.1.10",
"fast-json-stable-stringify": "^2.1.0",
"handlebars": "^4.7.8",
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.2",
"semver": "^7.7.3",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
},
@@ -55453,9 +55412,9 @@
}
},
"node_modules/ts-jest/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -59932,19 +59891,6 @@
"@octokit/openapi-types": "^25.1.0"
}
},
"packages/generator-superset/node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"packages/generator-superset/node_modules/@sinclair/typebox": {
"version": "0.34.38",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz",
@@ -61320,22 +61266,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/generator-superset/node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"packages/generator-superset/node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
@@ -64112,7 +64042,7 @@
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.3",
"ace-builds": "^1.43.4",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"brace": "^0.11.1",
@@ -64148,7 +64078,7 @@
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.14"
"xss": "^1.0.15"
},
"devDependencies": {
"@emotion/styled": "^11.14.1",
@@ -66579,19 +66509,6 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"plugins/plugin-chart-handlebars/node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"plugins/plugin-chart-handlebars/node_modules/@sinclair/typebox": {
"version": "0.34.37",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz",
@@ -67488,22 +67405,6 @@
"license": "BSD-3-Clause",
"peer": true
},
"plugins/plugin-chart-handlebars/node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"plugins/plugin-chart-pivot-table": {
"name": "@superset-ui/plugin-chart-pivot-table",
"version": "0.20.3",
@@ -67841,19 +67742,6 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"plugins/plugin-chart-pivot-table/node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"plugins/plugin-chart-pivot-table/node_modules/@sinclair/typebox": {
"version": "0.34.41",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
@@ -68713,22 +68601,6 @@
"source-map": "^0.6.0"
}
},
"plugins/plugin-chart-pivot-table/node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"plugins/plugin-chart-table": {
"name": "@superset-ui/plugin-chart-table",
"version": "0.20.3",

View File

@@ -213,7 +213,7 @@
"devDependencies": {
"@applitools/eyes-storybook": "^3.60.0",
"@babel/cli": "^7.28.3",
"@babel/compat-data": "^7.28.0",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.28.3",
"@babel/eslint-parser": "^7.28.4",
"@babel/node": "^7.22.6",
@@ -312,7 +312,7 @@
"fetch-mock": "^11.1.5",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"history": "^5.3.0",
"html-webpack-plugin": "^5.6.3",
"html-webpack-plugin": "^5.6.4",
"imports-loader": "^5.0.0",
"jest": "^30.0.2",
"jest-environment-jsdom": "^29.7.0",
@@ -324,7 +324,7 @@
"open-cli": "^8.0.0",
"po2json": "^0.4.5",
"prettier": "3.6.2",
"prettier-plugin-packagejson": "^2.5.3",
"prettier-plugin-packagejson": "^2.5.19",
"process": "^0.11.10",
"react-resizable": "^3.0.5",
"redux-mock-store": "^1.5.4",
@@ -335,7 +335,7 @@
"storybook": "8.1.11",
"style-loader": "^4.0.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.0",
"ts-jest": "^29.4.5",
"ts-loader": "^9.5.1",
"tscw-config": "^1.1.2",
"tsx": "^4.20.3",

View File

@@ -30,7 +30,7 @@
"@fontsource/fira-code": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"ace-builds": "^1.43.3",
"ace-builds": "^1.43.4",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"brace": "^0.11.1",

View File

@@ -28,6 +28,7 @@ export const StyledHeader = styled.span<{ headerPosition: string }>`
text-overflow: ellipsis;
white-space: nowrap;
margin-right: ${headerPosition === 'left' ? theme.sizeUnit * 2 : 0}px;
font-size: ${theme.fontSizeSM}px;
`}
`;

View File

@@ -324,6 +324,7 @@ export type Query = {
schema?: string;
sql: string;
sqlEditorId: string;
sqlEditorImmutableId: string;
state: QueryState;
tab: string | null;
tempSchema: string | null;
@@ -373,6 +374,7 @@ export const testQuery: Query = {
dbId: 1,
sql: 'SELECT * FROM something',
sqlEditorId: 'dfsadfs',
sqlEditorImmutableId: 'immutableId2353',
tab: 'unimportant',
tempTable: '',
ctas: false,

View File

@@ -402,7 +402,7 @@ export interface ThemeContextType {
setTheme: (config: AnyThemeConfig) => void;
setThemeMode: (newMode: ThemeMode) => void;
resetTheme: () => void;
setTemporaryTheme: (config: AnyThemeConfig) => void;
setTemporaryTheme: (config: AnyThemeConfig, themeId?: number | null) => void;
clearLocalOverrides: () => void;
getCurrentCrudThemeId: () => string | null;
hasDevOverride: () => boolean;
@@ -410,6 +410,7 @@ export interface ThemeContextType {
canSetTheme: () => boolean;
canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
getAppliedThemeId: () => number | null;
}
/**

View File

@@ -394,7 +394,7 @@ export function runQueryFromSqlEditor(
dbId: qe.dbId,
sql: qe.selectedText || qe.sql,
sqlEditorId: qe.tabViewId ?? qe.id,
immutableId: qe.immutableId,
sqlEditorImmutableId: qe.immutableId,
tab: qe.name,
catalog: qe.catalog,
schema: qe.schema,

View File

@@ -602,4 +602,42 @@ describe('ResultSet', () => {
);
expect(queryByTestId('copy-to-clipboard-button')).not.toBeInTheDocument();
});
test('should include sqlEditorImmutableId in query object when fetching results', async () => {
const queryWithResultsKey = {
...queries[0],
resultsKey: 'test-results-key',
sqlEditorImmutableId: 'test-immutable-id-123',
};
const store = mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queryWithResultsKey.id]: queryWithResultsKey,
},
},
});
setup({ ...mockedProps, queryId: queryWithResultsKey.id }, store);
await waitFor(() => {
// Check that REQUEST_QUERY_RESULTS action was dispatched
const actions = store.getActions();
const requestAction = actions.find(
action => action.type === 'REQUEST_QUERY_RESULTS',
);
expect(requestAction).toBeDefined();
// Verify sqlEditorImmutableId is present in the query object
expect(requestAction?.query?.sqlEditorImmutableId).toBe(
'test-immutable-id-123',
);
});
// Verify the API was called
const resultsCalls = fetchMock.calls('glob:*/api/v1/sqllab/results/*');
expect(resultsCalls).toHaveLength(1);
});
});

View File

@@ -198,6 +198,7 @@ const ResultSet = ({
'sql',
'executedSql',
'sqlEditorId',
'sqlEditorImmutableId',
'templateParams',
'schema',
'rows',

View File

@@ -238,6 +238,7 @@ export const queries = [
ctas: false,
cached: false,
id: 'BkA1CLrJg',
sqlEditorImmutableId: 'BkA1CLrJg_immutable',
progress: 100,
startDttm: 1476910566092.96,
state: QueryState.Success,
@@ -297,6 +298,7 @@ export const queries = [
ctas: false,
cached: false,
id: 'S1zeAISkx',
sqlEditorImmutableId: 'S1zeAISkx_immutable',
progress: 100,
startDttm: 1476910570802.2,
state: QueryState.Success,
@@ -331,6 +333,7 @@ export const queryWithNoQueryLimit = {
ctas: false,
cached: false,
id: 'BkA1CLrJg',
sqlEditorImmutableId: 'BkA1CLrJg_immutable',
progress: 100,
startDttm: 1476910566092.96,
state: QueryState.Success,
@@ -589,6 +592,7 @@ const baseQuery: QueryResponse = {
ctas: false,
cached: false,
id: 'BkA1CLrJg',
sqlEditorImmutableId: 'BkA1CLrJg_immutable',
progress: 100,
startDttm: 1476910566092.96,
state: QueryState.Success,
@@ -672,6 +676,7 @@ export const runningQuery: QueryResponse = {
cached: false,
ctas: false,
id: 'ryhMUZCGb',
sqlEditorImmutableId: 'ryhMUZCGb_immutable',
progress: 90,
state: QueryState.Running,
startDttm: Date.now() - 500,
@@ -683,6 +688,7 @@ export const successfulQuery: QueryResponse = {
cached: false,
ctas: false,
id: 'ryhMUZCGb',
sqlEditorImmutableId: 'ryhMUZCGb_immutable',
progress: 100,
state: QueryState.Success,
startDttm: Date.now() - 500,

View File

@@ -208,14 +208,15 @@ const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
// If we don't have a registration ID, don't filter events
if (!registrationImmutableId) return true;
// For query events, use the immutableId directly from the action payload
if (action.query?.immutableId) {
return action.query.immutableId === registrationImmutableId;
// For query events, use the sqlEditorImmutableId directly from the action payload
if (action.query?.sqlEditorImmutableId) {
return action.query.sqlEditorImmutableId === registrationImmutableId;
}
// For tab events, we need to find the immutable ID of the affected tab
if (action.queryEditor?.id) {
const queryEditor = findQueryEditor(action.queryEditor.id);
const queryEditorId = action.queryEditor?.id || action.query?.sqlEditorId;
if (queryEditorId) {
const queryEditor = findQueryEditor(queryEditorId);
return queryEditor?.immutableId === registrationImmutableId;
}

View File

@@ -208,8 +208,7 @@ class TextAreaControl extends Component {
buttonSize="small"
style={{ marginTop: this.props.theme.sizeUnit }}
>
{t('Edit')} <strong>{this.props.language}</strong>{' '}
{t('in modal')}
{t('Edit %s in modal', this.props.language)}
</Button>
}
modalBody={this.renderModalBody(true)}

View File

@@ -66,6 +66,7 @@ export const mapQueryResponse = (
): Omit<
Query,
| 'tempSchema'
| 'sqlEditorImmutableId'
| 'started'
| 'time'
| 'duration'

View File

@@ -103,6 +103,9 @@ const Actions = styled.div`
}
}
color: ${theme.colorTextDisabled};
&:hover {
cursor: not-allowed;
}
.ant-menu-item:hover {
cursor: default;
}
@@ -479,7 +482,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
<span
role="button"
tabIndex={0}
className={allowEdit ? 'action-button' : 'disabled'}
className={`action-button ${allowEdit ? '' : 'disabled'}`}
onClick={allowEdit ? handleEdit : undefined}
>
<Icons.EditOutlined iconSize="l" />

View File

@@ -62,6 +62,7 @@ jest.mock('src/views/CRUD/hooks', () => ({
// Mock the useThemeContext hook
const mockSetTemporaryTheme = jest.fn();
const mockGetAppliedThemeId = jest.fn();
jest.mock('src/theme/ThemeProvider', () => ({
...jest.requireActual('src/theme/ThemeProvider'),
useThemeContext: jest.fn(),
@@ -141,10 +142,13 @@ beforeEach(() => {
});
// Mock useThemeContext
mockGetAppliedThemeId.mockReturnValue(null);
(useThemeContext as jest.Mock).mockReturnValue({
getCurrentCrudThemeId: jest.fn().mockReturnValue('1'),
appliedTheme: { theme_name: 'Light Theme', id: 1 },
setTemporaryTheme: mockSetTemporaryTheme,
hasDevOverride: jest.fn().mockReturnValue(false),
getAppliedThemeId: mockGetAppliedThemeId,
});
fetchMock.reset();
@@ -460,7 +464,7 @@ test('shows create theme button when user has permissions', async () => {
expect(addButton).toBeInTheDocument();
});
test('clicking apply button calls setTemporaryTheme with parsed theme data', async () => {
test('clicking apply button calls setTemporaryTheme with parsed theme data and ID', async () => {
render(
<ThemesList
user={mockUser}
@@ -483,8 +487,106 @@ test('clicking apply button calls setTemporaryTheme with parsed theme data', asy
await userEvent.click(applyButtons[0]);
await waitFor(() => {
expect(mockSetTemporaryTheme).toHaveBeenCalledWith({
colors: { primary: '#ffffff' },
});
expect(mockSetTemporaryTheme).toHaveBeenCalledWith(
{
colors: { primary: '#ffffff' },
},
1, // theme ID
);
});
});
test('applying a local theme calls setTemporaryTheme with theme ID', async () => {
render(
<ThemesList
user={mockUser}
addDangerToast={jest.fn()}
addSuccessToast={jest.fn()}
/>,
{
useRedux: true,
useRouter: true,
useQueryParams: true,
useTheme: true,
},
);
await screen.findByText('Custom Theme');
// Find and click the apply button for the first theme
const applyButtons = await screen.findAllByTestId('apply-action');
await userEvent.click(applyButtons[0]);
// Check that setTemporaryTheme was called with both theme config and ID
await waitFor(() => {
expect(mockSetTemporaryTheme).toHaveBeenCalledWith(
{ colors: { primary: '#ffffff' } },
1, // theme ID
);
});
});
test('component loads successfully with applied theme ID set', async () => {
// This test verifies that having a stored theme ID doesn't break the component
// Mock hasDevOverride to return true since we have a dev override set
mockGetAppliedThemeId.mockReturnValue(1);
(useThemeContext as jest.Mock).mockReturnValue({
getCurrentCrudThemeId: jest.fn().mockReturnValue('1'),
appliedTheme: { theme_name: 'Light Theme', id: 1 },
setTemporaryTheme: mockSetTemporaryTheme,
hasDevOverride: jest.fn().mockReturnValue(true),
getAppliedThemeId: mockGetAppliedThemeId,
});
render(
<ThemesList
user={mockUser}
addDangerToast={jest.fn()}
addSuccessToast={jest.fn()}
/>,
{
useRedux: true,
useRouter: true,
useQueryParams: true,
useTheme: true,
},
);
// Wait for list to load and verify it renders successfully
await screen.findByText('Custom Theme');
// Verify the component called getAppliedThemeId
expect(mockGetAppliedThemeId).toHaveBeenCalled();
});
test('component loads successfully and preserves applied theme state', async () => {
// Mock hasDevOverride to return true and getAppliedThemeId to return a theme
mockGetAppliedThemeId.mockReturnValue(1);
(useThemeContext as jest.Mock).mockReturnValue({
getCurrentCrudThemeId: jest.fn().mockReturnValue('1'),
appliedTheme: { theme_name: 'Light Theme', id: 1 },
setTemporaryTheme: mockSetTemporaryTheme,
hasDevOverride: jest.fn().mockReturnValue(true),
getAppliedThemeId: mockGetAppliedThemeId,
});
render(
<ThemesList
user={mockUser}
addDangerToast={jest.fn()}
addSuccessToast={jest.fn()}
/>,
{
useRedux: true,
useRouter: true,
useQueryParams: true,
useTheme: true,
},
);
// Wait for list to load
await screen.findByText('Custom Theme');
// Verify getAppliedThemeId is called during component mount
expect(mockGetAppliedThemeId).toHaveBeenCalled();
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { t, SupersetClient, styled } from '@superset-ui/core';
import {
Tag,
@@ -110,15 +110,27 @@ function ThemesList({
refreshData,
toggleBulkSelect,
} = useListViewResource<ThemeObject>('theme', t('Themes'), addDangerToast);
const { setTemporaryTheme, getCurrentCrudThemeId } = useThemeContext();
const { setTemporaryTheme, hasDevOverride, getAppliedThemeId } =
useThemeContext();
const [themeModalOpen, setThemeModalOpen] = useState<boolean>(false);
const [currentTheme, setCurrentTheme] = useState<ThemeObject | null>(null);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
const [importingTheme, showImportModal] = useState<boolean>(false);
const [appliedThemeId, setAppliedThemeId] = useState<number | null>(null);
const [appliedThemeId, setLocalAppliedThemeId] = useState<number | null>(
null,
);
const { showConfirm, ConfirmModal } = useConfirmModal();
useEffect(() => {
if (hasDevOverride()) {
const storedThemeId = getAppliedThemeId();
setLocalAppliedThemeId(storedThemeId);
} else {
setLocalAppliedThemeId(null);
}
}, [hasDevOverride, getAppliedThemeId]);
const canCreate = hasPerm('can_write');
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
@@ -201,8 +213,11 @@ function ThemesList({
if (themeObj.json_data) {
try {
const themeConfig = JSON.parse(themeObj.json_data);
setTemporaryTheme(themeConfig);
setAppliedThemeId(themeObj.id || null);
const themeId = themeObj.id || null;
setTemporaryTheme(themeConfig, themeId);
setLocalAppliedThemeId(themeId);
addSuccessToast(t('Local theme set to "%s"', themeObj.theme_name));
} catch (error) {
addDangerToast(
@@ -217,23 +232,26 @@ function ThemesList({
function handleThemeModalApply() {
// Clear any previously applied theme ID when applying from modal
// since the modal theme might not have an ID yet (unsaved theme)
setAppliedThemeId(null);
setLocalAppliedThemeId(null);
}
const handleBulkThemeExport = async (themesToExport: ThemeObject[]) => {
const ids = themesToExport
.map(({ id }) => id)
.filter((id): id is number => id !== undefined);
setPreparingExport(true);
try {
await handleResourceExport('theme', ids, () => {
const handleBulkThemeExport = useCallback(
async (themesToExport: ThemeObject[]) => {
const ids = themesToExport
.map(({ id }) => id)
.filter((id): id is number => id !== undefined);
setPreparingExport(true);
try {
await handleResourceExport('theme', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected themes'));
}
};
addDangerToast(t('There was an issue exporting the selected themes'));
}
},
[addDangerToast],
);
const openThemeImportModal = () => {
showImportModal(true);
@@ -346,11 +364,10 @@ function ThemesList({
() => [
{
Cell: ({ row: { original } }: any) => {
const currentCrudThemeId = getCurrentCrudThemeId();
const isCurrentTheme =
(currentCrudThemeId &&
original.id?.toString() === currentCrudThemeId) ||
(appliedThemeId && original.id === appliedThemeId);
hasDevOverride() &&
appliedThemeId &&
original.id === appliedThemeId;
return (
<FlexRowContainer>
@@ -520,11 +537,12 @@ function ThemesList({
canDelete,
canApply,
canExport,
getCurrentCrudThemeId,
hasDevOverride,
appliedThemeId,
canSetSystemThemes,
addDangerToast,
handleThemeApply,
handleBulkThemeExport,
handleSetSystemDefault,
handleUnsetSystemDefault,
handleSetSystemDark,

View File

@@ -22,6 +22,7 @@ import {
type ThemeControllerOptions,
type ThemeStorage,
isThemeConfigDark,
makeApi,
Theme,
ThemeMode,
themeObject as supersetThemeObject,
@@ -37,6 +38,7 @@ const STORAGE_KEYS = {
THEME_MODE: 'superset-theme-mode',
CRUD_THEME_ID: 'superset-crud-theme-id',
DEV_THEME_OVERRIDE: 'superset-dev-theme-override',
APPLIED_THEME_ID: 'superset-applied-theme-id',
} as const;
const MEDIA_QUERY_DARK_SCHEME = '(prefers-color-scheme: dark)';
@@ -224,14 +226,14 @@ export class ThemeController {
return this.dashboardThemes.get(themeId)!;
}
// Fetch theme config from API
const response = await fetch(`/api/v1/theme/${themeId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Fetch theme config from API using SupersetClient for proper auth
const getTheme = makeApi<void, { result: { json_data: string } }>({
method: 'GET',
endpoint: `/api/v1/theme/${themeId}`,
});
const data = await response.json();
const themeConfig = JSON.parse(data.result.json_data);
const { result } = await getTheme();
const themeConfig = JSON.parse(result.json_data);
if (themeConfig) {
// Controller creates and owns the dashboard theme
@@ -303,7 +305,12 @@ export class ThemeController {
public setThemeMode(mode: ThemeMode): void {
this.validateModeUpdatePermission(mode);
if (this.currentMode === mode) return;
if (
this.currentMode === mode &&
!this.devThemeOverride &&
!this.crudThemeId
)
return;
// Clear any local overrides when explicitly selecting a theme mode
// This ensures the selected mode takes effect and provides clear UX
@@ -367,8 +374,12 @@ export class ThemeController {
* Sets a temporary theme override for development purposes.
* This does not persist the theme but allows live preview.
* @param theme - The theme configuration to apply temporarily
* @param themeId - Optional theme ID to track which theme was applied (for UI display)
*/
public setTemporaryTheme(theme: AnyThemeConfig): void {
public setTemporaryTheme(
theme: AnyThemeConfig,
themeId?: number | null,
): void {
this.validateThemeUpdatePermission();
this.devThemeOverride = theme;
@@ -377,6 +388,11 @@ export class ThemeController {
JSON.stringify(theme),
);
// Store the theme ID if provided
if (themeId !== undefined) {
this.setAppliedThemeId(themeId);
}
const mergedTheme = this.getThemeForMode(this.currentMode);
if (mergedTheme) this.updateTheme(mergedTheme);
}
@@ -392,6 +408,7 @@ export class ThemeController {
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
// Clear dashboard themes cache
this.dashboardThemes.clear();
@@ -413,6 +430,34 @@ export class ThemeController {
return this.devThemeOverride !== null;
}
/**
* Gets the applied theme ID (for UI display purposes).
*/
public getAppliedThemeId(): number | null {
try {
const storedId = this.storage.getItem(STORAGE_KEYS.APPLIED_THEME_ID);
return storedId ? parseInt(storedId, 10) : null;
} catch (error) {
console.warn('Failed to get applied theme ID:', error);
return null;
}
}
/**
* Sets the applied theme ID (for UI display purposes).
*/
public setAppliedThemeId(themeId: number | null): void {
try {
if (themeId !== null) {
this.storage.setItem(STORAGE_KEYS.APPLIED_THEME_ID, themeId.toString());
} else {
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
}
} catch (error) {
console.warn('Failed to set applied theme ID:', error);
}
}
/**
* Checks if OS preference detection is allowed.
* Allowed when dark theme is available (including base dark theme)
@@ -817,13 +862,14 @@ export class ThemeController {
themeId: string,
): Promise<AnyThemeConfig | null> {
try {
const response = await fetch(`/api/v1/theme/${themeId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Use SupersetClient for proper authentication handling
const getTheme = makeApi<void, { result: { json_data: string } }>({
method: 'GET',
endpoint: `/api/v1/theme/${themeId}`,
});
const data = await response.json();
const themeConfig = JSON.parse(data.result.json_data);
const { result } = await getTheme();
const themeConfig = JSON.parse(result.json_data);
return themeConfig;
} catch (error) {

View File

@@ -117,6 +117,11 @@ export function SupersetThemeProvider({
[themeController],
);
const getAppliedThemeId = useCallback(
() => themeController.getAppliedThemeId(),
[themeController],
);
const contextValue = useMemo(
() => ({
theme: currentTheme,
@@ -132,6 +137,7 @@ export function SupersetThemeProvider({
canSetTheme,
canDetectOSPreference,
createDashboardThemeProvider,
getAppliedThemeId,
}),
[
currentTheme,
@@ -147,6 +153,7 @@ export function SupersetThemeProvider({
canSetTheme,
canDetectOSPreference,
createDashboardThemeProvider,
getAppliedThemeId,
],
);

View File

@@ -1137,4 +1137,236 @@ describe('ThemeController', () => {
);
});
});
test('setThemeMode clears dev override and crud theme from storage', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
// Simulate having overrides after initialization using Reflect to access private properties
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
Reflect.set(controller, 'crudThemeId', '123');
jest.clearAllMocks();
// Change theme mode - should clear the overrides
controller.setThemeMode(ThemeMode.DARK);
// Verify both storage keys were removed
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
});
test('setThemeMode can be called with same mode when overrides exist', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
// Simulate having dev override after initialization using Reflect
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
// Call setThemeMode with DEFAULT mode - should clear override
controller.setThemeMode(ThemeMode.DEFAULT);
// Verify override was removed even though mode is the same
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
// Theme should still be updated to clear the override
expect(mockSetConfig).toHaveBeenCalled();
});
test('setThemeMode with no override and same mode does not trigger update', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
// Set mode to DEFAULT
controller.setThemeMode(ThemeMode.DEFAULT);
jest.clearAllMocks();
// Call again with same mode and no override - should skip
controller.setThemeMode(ThemeMode.DEFAULT);
// Should not trigger any updates
expect(mockSetConfig).not.toHaveBeenCalled();
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
});
test('hasDevOverride returns true when dev override is set', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
// Simulate dev override after initialization using Reflect
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
expect(controller.hasDevOverride()).toBe(true);
});
test('hasDevOverride returns false when no dev override in storage', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.hasDevOverride()).toBe(false);
});
test('clearLocalOverrides removes dev override, crud theme, and applied theme ID', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
// Clear overrides
controller.clearLocalOverrides();
// Verify all storage keys are removed
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
// Should reset to default theme
expect(mockSetConfig).toHaveBeenCalled();
});
test('getAppliedThemeId returns stored theme ID', () => {
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'superset-applied-theme-id') {
return '42';
}
return null;
});
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.getAppliedThemeId()).toBe(42);
});
test('getAppliedThemeId returns null when no theme ID is stored', () => {
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.getAppliedThemeId()).toBeNull();
});
test('setAppliedThemeId stores theme ID in storage', () => {
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
controller.setAppliedThemeId(123);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
'123',
);
});
test('setAppliedThemeId removes theme ID when null is passed', () => {
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
controller.setAppliedThemeId(null);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
});
});

View File

@@ -25,11 +25,11 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.20",
"@types/node": "^24.8.1",
"@types/node": "^24.9.1",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.46.1",
"@typescript-eslint/parser": "^8.46.2",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
@@ -40,7 +40,7 @@
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1"
"typescript-eslint": "^8.46.2"
},
"engines": {
"node": "^20.19.4",
@@ -1859,13 +1859,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.14.0"
"undici-types": "~7.16.0"
}
},
"node_modules/@types/stack-utils": {
@@ -1911,17 +1911,17 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
"integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/type-utils": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/type-utils": "8.46.2",
"@typescript-eslint/utils": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -1935,7 +1935,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.46.1",
"@typescript-eslint/parser": "^8.46.2",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@@ -1951,16 +1951,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"debug": "^4.3.4"
},
"engines": {
@@ -1976,14 +1976,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz",
"integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.46.1",
"@typescript-eslint/types": "^8.46.1",
"@typescript-eslint/tsconfig-utils": "^8.46.2",
"@typescript-eslint/types": "^8.46.2",
"debug": "^4.3.4"
},
"engines": {
@@ -1998,14 +1998,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
"integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1"
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2016,9 +2016,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz",
"integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2033,15 +2033,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz",
"integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/utils": "8.46.2",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -2058,9 +2058,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2072,16 +2072,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
"integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.46.1",
"@typescript-eslint/tsconfig-utils": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/project-service": "8.46.2",
"@typescript-eslint/tsconfig-utils": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -2127,16 +2127,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz",
"integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1"
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2151,13 +2151,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
"integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/types": "8.46.2",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -6306,16 +6306,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
"integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1"
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/utils": "8.46.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6344,9 +6344,9 @@
}
},
"node_modules/undici-types": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
@@ -8057,12 +8057,12 @@
"dev": true
},
"@types/node": {
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"requires": {
"undici-types": "~7.14.0"
"undici-types": "~7.16.0"
}
},
"@types/stack-utils": {
@@ -8107,16 +8107,16 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
"integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/type-utils": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/type-utils": "8.46.2",
"@typescript-eslint/utils": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -8132,75 +8132,75 @@
}
},
"@typescript-eslint/parser": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"debug": "^4.3.4"
}
},
"@typescript-eslint/project-service": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz",
"integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==",
"dev": true,
"requires": {
"@typescript-eslint/tsconfig-utils": "^8.46.1",
"@typescript-eslint/types": "^8.46.1",
"@typescript-eslint/tsconfig-utils": "^8.46.2",
"@typescript-eslint/types": "^8.46.2",
"debug": "^4.3.4"
}
},
"@typescript-eslint/scope-manager": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
"integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1"
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2"
}
},
"@typescript-eslint/tsconfig-utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz",
"integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==",
"dev": true,
"requires": {}
},
"@typescript-eslint/type-utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz",
"integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/utils": "8.46.2",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
}
},
"@typescript-eslint/types": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
"integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
"dev": true,
"requires": {
"@typescript-eslint/project-service": "8.46.1",
"@typescript-eslint/tsconfig-utils": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/project-service": "8.46.2",
"@typescript-eslint/tsconfig-utils": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -8230,24 +8230,24 @@
}
},
"@typescript-eslint/utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz",
"integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1"
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2"
}
},
"@typescript-eslint/visitor-keys": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
"integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/types": "8.46.2",
"eslint-visitor-keys": "^4.2.1"
},
"dependencies": {
@@ -11259,15 +11259,15 @@
"dev": true
},
"typescript-eslint": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
"integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
"dev": true,
"requires": {
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1"
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/utils": "8.46.2"
}
},
"uglify-js": {
@@ -11278,9 +11278,9 @@
"optional": true
},
"undici-types": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true
},
"unix-dgram": {

View File

@@ -33,11 +33,11 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.20",
"@types/node": "^24.8.1",
"@types/node": "^24.9.1",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.46.1",
"@typescript-eslint/parser": "^8.46.2",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
@@ -48,7 +48,7 @@
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1"
"typescript-eslint": "^8.46.2"
},
"engines": {
"node": "^20.19.4",

View File

@@ -30,6 +30,7 @@ from typing import Any, Optional, TYPE_CHECKING, Union
import numpy as np
import pandas as pd
from flask import current_app
from flask_babel import gettext as __
from superset.common.chart_data import ChartDataResultFormat
@@ -340,7 +341,15 @@ def apply_client_processing( # noqa: C901
if query["result_format"] == ChartDataResultFormat.JSON:
df = pd.DataFrame.from_dict(data)
elif query["result_format"] == ChartDataResultFormat.CSV:
df = pd.read_csv(StringIO(data))
# Use custom NA values configuration for
# reports to avoid unwanted conversions
# This allows users to control which values should be treated as null/NA
na_values = current_app.config["REPORTS_CSV_NA_NAMES"]
df = pd.read_csv(
StringIO(data),
keep_default_na=na_values is None,
na_values=na_values,
)
# convert all columns to verbose (label) name
if datasource:

View File

@@ -1354,6 +1354,15 @@ ALLOWED_USER_CSV_SCHEMA_FUNC = allowed_schemas_for_csv_upload
# Values that should be treated as nulls for the csv uploads.
CSV_DEFAULT_NA_NAMES = list(STR_NA_VALUES)
# Values that should be treated as nulls for scheduled reports CSV processing.
# If not set or None, defaults to standard pandas NA handling behavior.
# Set to a custom list to control which values should be treated as null.
# Examples:
# REPORTS_CSV_NA_NAMES = None # Use default pandas NA handling (backwards compatible)
# REPORTS_CSV_NA_NAMES = [] # Disable all automatic NA conversion
# REPORTS_CSV_NA_NAMES = ["", "NULL", "null"] # Only treat these specific values as NA
REPORTS_CSV_NA_NAMES: list[str] | None = None
# Chunk size for reading CSV files during uploads
# Smaller values use less memory but may be slower for large files
READ_CSV_CHUNK_SIZE = 1000

View File

@@ -59,6 +59,7 @@ class GuestUser(AnonymousUserMixin):
"""
is_guest_user = True
active = True
@property
def is_authenticated(self) -> bool:

View File

@@ -120,15 +120,26 @@ def take_tiled_screenshot(
dashboard_top,
)
# Calculate number of tiles needed
num_tiles = max(1, (dashboard_height + viewport_height - 1) // viewport_height)
# Get actual viewport height to ensure we don't skip content
actual_viewport_height = page.viewport_size["height"]
tile_height = min(viewport_height, actual_viewport_height)
logger.info(
"Viewport: configured=%s, actual=%s, using tile_height=%s",
viewport_height,
actual_viewport_height,
tile_height,
)
# Calculate number of tiles needed based on actual tile height
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
logger.info("Taking %s screenshot tiles", num_tiles)
screenshot_tiles = []
for i in range(num_tiles):
# Calculate scroll position to show this tile's content
scroll_y = dashboard_top + (i * viewport_height)
scroll_y = dashboard_top + (i * tile_height)
# Scroll the window to the desired position
page.evaluate(f"window.scrollTo(0, {scroll_y})")
@@ -139,29 +150,65 @@ def take_tiled_screenshot(
# Wait for scroll to settle and content to load
page.wait_for_timeout(2000) # 2 second wait per tile
# Get the current element position after scroll
current_element_box = page.evaluate(f"""() => {{
# Get the current element position after scroll and viewport size
viewport_info = page.evaluate(f"""() => {{
const el = document.querySelector(".{element_name}");
const rect = el.getBoundingClientRect();
return {{
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
elementX: rect.left,
elementY: rect.top,
elementWidth: rect.width,
elementHeight: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
}};
}}""")
# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * viewport_height
remaining_content = dashboard_height - tile_start_in_element
tile_content_height = min(viewport_height, remaining_content)
# Ensure clip coordinates are within viewport bounds
# If element.top is negative, it's scrolled above viewport - start from y=0
clip_y = max(0, viewport_info["elementY"])
# If element.left is negative, start from x=0
clip_x = max(0, viewport_info["elementX"])
# Calculate clip dimensions - capture what's visible of the element
# Handle elements scrolled above viewport: if elementY is negative,
# only the portion from (elementY + elementHeight) is visible
if viewport_info["elementY"] < 0:
# Element extends from above viewport - calculate visible portion
visible_height = (
viewport_info["elementY"] + viewport_info["elementHeight"]
)
clip_height = min(visible_height, viewport_info["viewportHeight"])
else:
# Element is within viewport
clip_height = min(
viewport_info["elementHeight"],
viewport_info["viewportHeight"] - clip_y,
)
clip_width = min(
viewport_info["elementWidth"], viewport_info["viewportWidth"] - clip_x
)
# Validate clip region before taking screenshot
if clip_width <= 0 or clip_height <= 0:
logger.warning(
"Skipping tile %s/%s - invalid clip dimensions: %sx%s at (%s, %s)",
i + 1,
num_tiles,
clip_width,
clip_height,
clip_x,
clip_y,
)
continue
# Clip to capture only the current tile portion of the element
clip = {
"x": current_element_box["x"],
"y": current_element_box["y"],
"width": current_element_box["width"],
"height": min(tile_content_height, current_element_box["height"]),
"x": clip_x,
"y": clip_y,
"width": clip_width,
"height": clip_height,
}
# Take screenshot with clipping to capture only this tile's content

View File

@@ -24,6 +24,7 @@ from sqlalchemy.orm.session import Session
from superset.charts.client_processing import apply_client_processing, pivot_df, table
from superset.common.chart_data import ChartDataResultFormat
from superset.utils.core import GenericDataType
from tests.conftest import with_config
def test_pivot_df_no_cols_no_rows_single_metric():
@@ -2653,3 +2654,137 @@ def test_pivot_multi_level_index():
| ('Total (Sum)', '', '') | 210 | 105 | 0 |
""".strip()
)
@with_config({"REPORTS_CSV_NA_NAMES": []})
def test_apply_client_processing_csv_format_preserves_na_strings():
"""
Test that apply_client_processing preserves "NA" when REPORTS_CSV_NA_NAMES is [].
This ensures that scheduled reports can be configured to
preserve strings like "NA" as literal values.
"""
# CSV data with "NA" string that should be preserved
csv_data = "first_name,last_name\nJeff,Smith\nAlice,NA"
result = {
"queries": [
{
"result_format": ChartDataResultFormat.CSV,
"data": csv_data,
}
]
}
form_data = {
"datasource": "1__table",
"viz_type": "table",
"slice_id": 1,
"url_params": {},
"metrics": [],
"groupby": [],
"columns": ["first_name", "last_name"],
"extra_form_data": {},
"force": False,
"result_format": "csv",
"result_type": "results",
}
# Test with REPORTS_CSV_NA_NAMES set to empty list (disable NA conversion)
processed_result = apply_client_processing(result, form_data)
# Verify the CSV data still contains "NA" as string, not converted to null
output_data = processed_result["queries"][0]["data"]
assert "NA" in output_data
# The "NA" should be preserved in the output CSV
lines = output_data.strip().split("\n")
assert "Alice,NA" in lines[2] # Second data row should preserve "NA"
@with_config({"REPORTS_CSV_NA_NAMES": ["MISSING"]})
def test_apply_client_processing_csv_format_custom_na_values():
"""
Test that apply_client_processing respects custom NA values configuration.
"""
csv_data = "name,status\nJeff,MISSING\nAlice,OK"
result = {
"queries": [
{
"result_format": ChartDataResultFormat.CSV,
"data": csv_data,
}
]
}
form_data = {
"datasource": "1__table",
"viz_type": "table",
"slice_id": 1,
"url_params": {},
"metrics": [],
"groupby": [],
"columns": ["name", "status"],
"extra_form_data": {},
"force": False,
"result_format": "csv",
"result_type": "results",
}
# Test with custom NA values - only "MISSING" should be treated as NA
processed_result = apply_client_processing(result, form_data)
output_data = processed_result["queries"][0]["data"]
lines = output_data.strip().split("\n")
assert len(lines) >= 3 # header + 2 data rows
assert "Jeff," in lines[1] # First data row should have empty status after "Jeff,"
assert "Alice,OK" in lines[2] # Second data row should preserve "OK"
@with_config({"REPORTS_CSV_NA_NAMES": []})
def test_apply_client_processing_csv_format_default_na_behavior():
"""
Test that apply_client_processing uses default pandas NA behavior
when REPORTS_CSV_NA_NAMES is not configured.
This ensures backwards compatibility.
"""
# CSV data with "NA" string that should be converted to null in default behavior
csv_data = "first_name,last_name\nJeff,Smith\nAlice,NA"
result = {
"queries": [
{
"result_format": ChartDataResultFormat.CSV,
"data": csv_data,
}
]
}
form_data = {
"datasource": "1__table",
"viz_type": "table",
"slice_id": 1,
"url_params": {},
"metrics": [],
"groupby": [],
"columns": ["first_name", "last_name"],
"extra_form_data": {},
"force": False,
"result_format": "csv",
"result_type": "results",
}
processed_result = apply_client_processing(result, form_data)
# Verify the CSV data has "NA" converted to empty (default pandas behavior)
output_data = processed_result["queries"][0]["data"]
lines = output_data.strip().split("\n")
assert len(lines) >= 3 # header + 2 data rows
# The "NA" should be converted to empty by default pandas behavior
assert (
"Alice," in lines[2]
) # Second data row should have empty last_name (NA converted to null)

View File

@@ -127,6 +127,21 @@ def fake_get_chart_csv_data_hierarchical(chart_url, auth_cookies=None):
return json.dumps(fake_result).encode("utf-8")
def fake_get_chart_csv_data_with_na_values(chart_url, auth_cookies=None):
# Return JSON with data containing "NA" string value that will be treated as null
fake_result = {
"result": [
{
"data": {"first_name": ["Jeff", "Alice"], "last_name": ["Smith", "NA"]},
"coltypes": [GenericDataType.STRING, GenericDataType.STRING],
"colnames": ["first_name", "last_name"],
"indexnames": ["idx1", "idx2"],
}
]
}
return json.dumps(fake_result).encode("utf-8")
def test_df_to_escaped_csv():
df = pd.DataFrame(
data={
@@ -263,3 +278,42 @@ def test_get_chart_dataframe_with_hierarchical_columns(monkeypatch: pytest.Monke
| ('idx',) | 2 |
"""
assert markdown_str.strip() == expected_markdown_str.strip()
def test_get_chart_dataframe_preserves_na_string_values(
monkeypatch: pytest.MonkeyPatch,
):
"""
Test that get_chart_dataframe currently preserves rows containing "NA"
string values.
This test verifies the existing behavior before implementing custom NA handling.
"""
monkeypatch.setattr(
csv, "get_chart_csv_data", fake_get_chart_csv_data_with_na_values
)
df = get_chart_dataframe("http://dummy-url")
assert df is not None
# Verify the DataFrame structure
expected_columns = pd.MultiIndex.from_tuples([("first_name",), ("last_name",)])
pd.testing.assert_index_equal(df.columns, expected_columns)
expected_index = pd.MultiIndex.from_tuples([("idx1",), ("idx2",)])
pd.testing.assert_index_equal(df.index, expected_index)
# Check that we have both rows initially
assert len(df) == 2
# Verify the data contains the "NA" string value (not converted to NaN)
pd.testing.assert_series_equal(
df[("first_name",)],
pd.Series(["Jeff", "Alice"], name=("first_name",), index=df.index),
)
pd.testing.assert_series_equal(
df[("last_name",)],
pd.Series(["Smith", "NA"], name=("last_name",), index=df.index),
)
last_name_values = df[("last_name",)].values
assert last_name_values[0] == "Smith"
assert last_name_values[1] == "NA"

View File

@@ -110,24 +110,42 @@ class TestTakeTiledScreenshot:
"""Create a mock Playwright page object."""
page = MagicMock()
# Mock viewport size
page.viewport_size = {"width": 1024, "height": 768}
# Mock element locator
element = MagicMock()
page.locator.return_value = element
# Mock element info - simulating a 5000px tall dashboard
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (5000px / 2000px = 2.5, rounded up to 3):
# 1 initial call + 3 scroll + 3 element box + 1 reset scroll = 8 calls
# For 7 tiles (5000px / 768px actual viewport = 6.5, rounded up to 7):
# 1 initial call + 7 scroll + 7 viewport info + 1 reset scroll = 16 calls
page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None,
viewport_info, # Tile 5
None,
viewport_info, # Tile 6
None,
viewport_info, # Tile 7
None, # Final reset scroll call
]
@@ -150,8 +168,8 @@ class TestTakeTiledScreenshot:
assert result == b"combined_screenshot"
# Should have called screenshot method multiple times
# (3 tiles for 5000px height)
assert mock_page.screenshot.call_count == 3
# (7 tiles for 5000px height with 768px actual viewport)
assert mock_page.screenshot.call_count == 7
# Should have called combine function
mock_combine.assert_called_once()
@@ -171,16 +189,23 @@ class TestTakeTiledScreenshot:
"""Test that tiles are calculated correctly."""
# Mock dashboard height of 3500px with viewport of 2000px
element_info = {"height": 3500, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 2 tiles (3500px / 2000px = 1.75, rounded up to 2):
# 1 initial call + 2 scroll + 2 element box + 1 reset scroll = 6 calls
# 1 initial call + 2 scroll + 2 viewport info + 1 reset scroll = 6 calls
mock_page.evaluate.side_effect = [
element_info,
None, # First scroll call
element_box, # First element box call
viewport_info, # First viewport info call
None, # Second scroll call
element_box, # Second element box call
viewport_info, # Second viewport info call
None, # Reset scroll call
]
@@ -198,38 +223,57 @@ class TestTakeTiledScreenshot:
"""Test that scroll positions are calculated correctly."""
# Override the fixture's side_effect for this specific test
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None,
viewport_info, # Tile 5
None,
viewport_info, # Tile 6
None,
viewport_info, # Tile 7
None, # Reset scroll call
]
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Check scroll positions (dashboard_top = 100)
# Check scroll positions (dashboard_top = 100, tile_height = 768)
scroll_calls = [
call
for call in mock_page.evaluate.call_args_list
if "scrollTo" in str(call)
]
# Should have scrolled to positions: 100, 2100, 4100
# Should have scrolled to positions: 100, 868, 1636, 2404, 3172, 3940, 4708
expected_scrolls = [
"window.scrollTo(0, 100)",
"window.scrollTo(0, 2100)",
"window.scrollTo(0, 4100)",
"window.scrollTo(0, 868)",
"window.scrollTo(0, 1636)",
"window.scrollTo(0, 2404)",
"window.scrollTo(0, 3172)",
"window.scrollTo(0, 3940)",
"window.scrollTo(0, 4708)",
]
actual_scrolls = [call[0][0] for call in scroll_calls]
assert len(actual_scrolls) == 4 # 3 tile scrolls + 1 reset
assert len(actual_scrolls) == 8 # 7 tile scrolls + 1 reset
for expected in expected_scrolls:
assert expected in actual_scrolls
@@ -237,16 +281,31 @@ class TestTakeTiledScreenshot:
"""Test that scroll position is reset after screenshot."""
# Override the fixture's side_effect for this specific test
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None,
viewport_info, # Tile 5
None,
viewport_info, # Tile 6
None,
viewport_info, # Tile 7
None, # Reset scroll call
]
@@ -268,7 +327,7 @@ class TestTakeTiledScreenshot:
"Dashboard: %sx%spx at (%s, %s)", 800, 5000, 50, 100
)
# Should log number of tiles with lazy logging format
mock_logger.info.assert_any_call("Taking %s screenshot tiles", 3)
mock_logger.info.assert_any_call("Taking %s screenshot tiles", 7)
def test_exception_handling_returns_none(self):
"""Test that exceptions are handled and None is returned."""
@@ -289,8 +348,8 @@ class TestTakeTiledScreenshot:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should have called wait_for_timeout for each tile (3 tiles)
assert mock_page.wait_for_timeout.call_count == 3
# Should have called wait_for_timeout for each tile (7 tiles)
assert mock_page.wait_for_timeout.call_count == 7
# Each wait should be 2000ms (2 seconds)
for call in mock_page.wait_for_timeout.call_args_list:
@@ -315,3 +374,367 @@ class TestTakeTiledScreenshot:
assert clip["width"] == 800
# Height should be min of viewport_height and remaining content
assert clip["height"] <= 600 # Element height from mock
def test_negative_element_position_clipped_to_zero(self):
"""Test that negative element positions are clipped to viewport bounds."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
# Mock element locator
element = MagicMock()
mock_page.locator.return_value = element
# Simulate element scrolled above viewport (negative Y position)
element_info = {"height": 3000, "top": 100, "left": 0, "width": 800}
viewport_info = {
"elementX": 0,
"elementY": -200, # Element is scrolled 200px above viewport
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 4 tiles (3000px / 768px = 3.9, rounded up to 4):
# 1 initial + 4 * (scroll + viewport info) + 1 reset = 10 calls
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should complete successfully
assert result is not None
# Check that clip Y was adjusted to 0 (not negative)
screenshot_calls = mock_page.screenshot.call_args_list
for call in screenshot_calls:
clip = call[1]["clip"]
assert clip["y"] >= 0, "Clip Y should never be negative"
assert clip["x"] >= 0, "Clip X should never be negative"
def test_element_extends_beyond_viewport(self):
"""Test clipping when element extends beyond viewport boundaries."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 0, "left": 0, "width": 1200}
# Element is wider than viewport
viewport_info = {
"elementX": 0,
"elementY": 100,
"elementWidth": 1200, # Wider than viewport
"elementHeight": 800,
"viewportWidth": 1024, # Viewport width
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
assert result is not None
# Check that clip width was constrained to viewport
clip = mock_page.screenshot.call_args_list[0][1]["clip"]
assert clip["width"] <= 1024, "Clip width should not exceed viewport"
def test_invalid_clip_dimensions_skipped(self):
"""Test that tiles with invalid dimensions are skipped with a warning."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 4000, "top": 0, "left": 0, "width": 800}
# First tile: valid
valid_viewport_info = {
"elementX": 0,
"elementY": 100,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# Second tile: invalid (negative height after calculation)
invalid_viewport_info = {
"elementX": 0,
"elementY": -1000, # Far above viewport
"elementWidth": 800,
"elementHeight": 100, # Not enough visible height
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 6 tiles (4000px / 768px = 5.2, rounded up to 6):
# 1 initial + 6 * (scroll + viewport info) + 1 reset = 14 calls
mock_page.evaluate.side_effect = [
element_info,
None,
valid_viewport_info, # Tile 1 - valid
None,
invalid_viewport_info, # Tile 2 - invalid, should be skipped
None,
valid_viewport_info, # Tile 3 - valid
None,
valid_viewport_info, # Tile 4 - valid
None,
valid_viewport_info, # Tile 5 - valid
None,
valid_viewport_info, # Tile 6 - valid
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page, "dashboard", viewport_height=2000
)
# Should complete but with warning
assert result is not None
# Should have logged a warning about skipping tile
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
assert "Skipping tile" in warning_msg
assert "invalid clip dimensions" in warning_msg
# Should have taken 5 screenshots (6 tiles - 1 invalid)
assert mock_page.screenshot.call_count == 5
def test_viewport_bounds_with_offset_element(self):
"""Test proper clipping for element with positive offset from viewport edge."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 500, "left": 200, "width": 600}
# Element starts 200px from left edge
viewport_info = {
"elementX": 200, # Offset from left
"elementY": 150,
"elementWidth": 600,
"elementHeight": 500,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
assert result is not None
# Check clip respects element position
clip = mock_page.screenshot.call_args_list[0][1]["clip"]
assert clip["x"] == 200, "Should preserve element X offset"
assert clip["y"] == 150, "Should preserve element Y offset"
assert clip["width"] == 600, "Should use element width"
def test_zero_width_element_skipped(self):
"""Test that elements with zero or negative width are skipped."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 0, "left": 0, "width": 0}
viewport_info = {
"elementX": 0,
"elementY": 100,
"elementWidth": 0, # Zero width
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
# All tiles will be skipped due to zero width
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1 - skipped
None,
viewport_info, # Tile 2 - skipped
None,
viewport_info, # Tile 3 - skipped
None, # Reset scroll
]
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page, "dashboard", viewport_height=2000
)
# Should handle gracefully
assert result is not None
# Should have logged warnings about invalid dimensions
# (3 times, once per tile)
assert mock_logger.warning.call_count == 3
for call in mock_logger.warning.call_args_list:
warning_msg = call[0][0]
assert "invalid clip dimensions" in warning_msg
# Should not have taken any screenshots
assert mock_page.screenshot.call_count == 0
def test_element_completely_above_viewport(self):
"""Test element that is completely scrolled above the viewport."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 0, "left": 0, "width": 800}
# Element completely above viewport
viewport_info = {
"elementX": 0,
"elementY": -800, # Completely above viewport
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
# All tiles will be skipped because element is completely above viewport
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1 - skipped
None,
viewport_info, # Tile 2 - skipped
None,
viewport_info, # Tile 3 - skipped
None, # Reset scroll
]
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page, "dashboard", viewport_height=2000
)
# Should handle gracefully
assert result is not None
# Should have skipped all 3 tiles with warnings
assert mock_logger.warning.call_count == 3
# Should not have taken screenshots
assert mock_page.screenshot.call_count == 0
def test_scroll_increment_respects_actual_viewport_height(self):
"""When config viewport height > actual viewport, we still cover every tile."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1600, "height": 1200}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 3600, "top": 0, "left": 0, "width": 800}
viewport_info = {
"elementX": 0,
"elementY": 0,
"elementWidth": 800,
"elementHeight": 1200,
"viewportWidth": 1600,
"viewportHeight": 1200,
}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll
viewport_info, # First viewport info
None, # Second scroll
viewport_info, # Second viewport info
None, # Third scroll
viewport_info, # Third viewport info
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# We expect three tiles (01200, 12002400, 24003600)
# even though config says 2000.
assert mock_page.screenshot.call_count == 3
scroll_calls = [
call
for call in mock_page.evaluate.call_args_list
if "scrollTo" in str(call)
]
actual_scrolls = [call[0][0] for call in scroll_calls]
# Should have scrolled to positions: 0, 1200, 2400, plus final reset to 0
assert len(actual_scrolls) == 4 # 3 tile scrolls + 1 reset
assert actual_scrolls == [
"window.scrollTo(0, 0)",
"window.scrollTo(0, 1200)",
"window.scrollTo(0, 2400)",
"window.scrollTo(0, 0)", # Reset scroll
]