mirror of
https://github.com/apache/superset.git
synced 2026-08-18 22:21:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74efbb7d8e | ||
|
|
3c90bdc6f0 | ||
|
|
ebab31adc2 | ||
|
|
936f073b9a | ||
|
|
d12320239a | ||
|
|
8ef7be5788 | ||
|
|
72458ab26f | ||
|
|
5105f13726 | ||
|
|
bdafb6c330 | ||
|
|
b4d79462ec | ||
|
|
49374f1fe5 | ||
|
|
f766de6d0d | ||
|
|
ed20e729d0 | ||
|
|
5bfc52b5cc | ||
|
|
13eb47a1da | ||
|
|
98136d547c | ||
|
|
e2070d79dc | ||
|
|
2807f1b0e8 | ||
|
|
c9c230142b | ||
|
|
2f8875aaef | ||
|
|
6e270df4a2 | ||
|
|
97eafd6140 | ||
|
|
3ed97f9691 | ||
|
|
5105899810 | ||
|
|
d917071708 | ||
|
|
afde126d9a | ||
|
|
b3a9b9beb4 | ||
|
|
70ba9c9552 | ||
|
|
98276cd1f3 | ||
|
|
cdeca0c179 | ||
|
|
aaf9eba161 | ||
|
|
1991e3f0d2 | ||
|
|
cfd40bdd0d | ||
|
|
a8216e3787 | ||
|
|
d114eb638b |
@@ -31,6 +31,7 @@ The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
`DatabaseRestApi.oauth2.error`. Update monitoring rules and dashboards that consume
|
||||
the old counter to use the outcome-specific replacements.
|
||||
|
||||
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
|
||||
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
|
||||
|
||||
@@ -97,6 +97,54 @@ for more information on how to configure it.
|
||||
|
||||
At the very least, you'll want to change `SECRET_KEY` and `SQLALCHEMY_DATABASE_URI`. Continue reading for more about each of these.
|
||||
|
||||
## Localizing D3 date and time labels
|
||||
|
||||
`BABEL_DEFAULT_LOCALE` controls Superset's application translations, while
|
||||
`D3_TIME_FORMAT` provides localized date and time names to visualizations that
|
||||
use the D3 formatter registry, including Calendar Heatmap. Configure both when
|
||||
you want the application and chart labels to use the same locale.
|
||||
|
||||
`D3_TIME_FORMAT` accepts partial overrides. For example, Russian month names
|
||||
can be configured in `superset_config.py` as follows:
|
||||
|
||||
```python
|
||||
BABEL_DEFAULT_LOCALE = "ru"
|
||||
|
||||
D3_TIME_FORMAT = {
|
||||
"months": [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
],
|
||||
"shortMonths": [
|
||||
"Янв",
|
||||
"Фев",
|
||||
"Мар",
|
||||
"Апр",
|
||||
"Май",
|
||||
"Июн",
|
||||
"Июл",
|
||||
"Авг",
|
||||
"Сен",
|
||||
"Окт",
|
||||
"Ноя",
|
||||
"Дек",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Restart Superset after changing `superset_config.py` so the frontend receives
|
||||
the updated formatter configuration.
|
||||
|
||||
## Chart-data query timing
|
||||
|
||||
Set `CHART_DATA_INCLUDE_TIMING = True` to add an optional versioned timing object
|
||||
|
||||
+7
-7
@@ -61,9 +61,9 @@
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.4",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001807",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
@@ -89,14 +89,14 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"webpack": "^5.109.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
+262
-244
@@ -1142,6 +1142,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768"
|
||||
integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==
|
||||
|
||||
"@babel/runtime@^8.0.0":
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-8.0.0.tgz#d7bd513e6843662346552c2798ab895716cf97f2"
|
||||
integrity sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==
|
||||
|
||||
"@babel/template@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
|
||||
@@ -3170,100 +3175,100 @@
|
||||
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.23.0.tgz#8b66dbfa7b796139e719063fc0e44084e80a1c15"
|
||||
integrity sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw==
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz#3f5b9d3ba944f42ad3fa2697b9fef88a8c9d4ce0"
|
||||
integrity sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==
|
||||
"@oxfmt/binding-android-arm-eabi@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz#136176dc94fdc41e21415cc770d86f5066282e0f"
|
||||
integrity sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==
|
||||
|
||||
"@oxfmt/binding-android-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz#4c7e2c567f645ed051be100318e9e3f716630c1b"
|
||||
integrity sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==
|
||||
"@oxfmt/binding-android-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz#10bc42457179210061c801122a64304619e3bdab"
|
||||
integrity sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==
|
||||
|
||||
"@oxfmt/binding-darwin-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz#6c8007ae65ed17f9d1ecc6c680da19ec19276c67"
|
||||
integrity sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==
|
||||
"@oxfmt/binding-darwin-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz#5f9084d9a760a1836387f8970a7f9d614ec3d909"
|
||||
integrity sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==
|
||||
|
||||
"@oxfmt/binding-darwin-x64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz#0661a0274e8625921c5a054aeb21a36251946e6b"
|
||||
integrity sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==
|
||||
"@oxfmt/binding-darwin-x64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz#badd4a02218a9a62319817d5c337b30159a54a21"
|
||||
integrity sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==
|
||||
|
||||
"@oxfmt/binding-freebsd-x64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz#f3345001102ac3e6c2947920d6d1676e9cf97e75"
|
||||
integrity sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==
|
||||
"@oxfmt/binding-freebsd-x64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz#a17261e95c8ebef1f76d8aaac746a64fdb6ba51e"
|
||||
integrity sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz#ddc03bc2a899f2071d6706c06dfdec3a7f3e8b5a"
|
||||
integrity sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==
|
||||
"@oxfmt/binding-linux-arm-gnueabihf@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz#baeee34bb08e0769af878623f442e83bc0aacd7a"
|
||||
integrity sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz#5e82208d612c4caf64ada75e129e34d1a9eefb2c"
|
||||
integrity sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==
|
||||
"@oxfmt/binding-linux-arm-musleabihf@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz#e70d5697ec4b6bb5f87a3f019e01b3f956b8e44b"
|
||||
integrity sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz#eb379bc58aa962e753d58b4cc68ff4081bc19a5d"
|
||||
integrity sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==
|
||||
"@oxfmt/binding-linux-arm64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz#638a8ed4f3d256c50aeb6d2c19cfc65792c902e1"
|
||||
integrity sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz#dc1c62510405e874bf6a53a34f548032eb6dfed7"
|
||||
integrity sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==
|
||||
"@oxfmt/binding-linux-arm64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz#af5a9b787f5233f27a3360ad56235fc1b011f760"
|
||||
integrity sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz#9f9afee327090024db86b70ec81a57ad06bb2f00"
|
||||
integrity sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==
|
||||
"@oxfmt/binding-linux-ppc64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz#c1a211206134a5577e355a495989e0d733218d60"
|
||||
integrity sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz#4427d42ee3bad0e55b38dc76fe14a7e5318c360c"
|
||||
integrity sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==
|
||||
"@oxfmt/binding-linux-riscv64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz#4863f0311e5c1b88f75ef822959b3ca4fd938937"
|
||||
integrity sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz#a117f82909f075cf07c333842d89a5638429e21d"
|
||||
integrity sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==
|
||||
"@oxfmt/binding-linux-riscv64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz#ad05a017d12553e2f544743c4940adb552aa1d1c"
|
||||
integrity sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz#3fac79fefe7ffc3f0a9393678ebd782aac918fcd"
|
||||
integrity sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==
|
||||
"@oxfmt/binding-linux-s390x-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz#2803f539db15bc66db115888fa8f84d6531ed2b9"
|
||||
integrity sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz#8da207bef27941f0265c129d1c7c82c7cf91d1ce"
|
||||
integrity sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==
|
||||
"@oxfmt/binding-linux-x64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz#c22a06a60ae2d6b3de522095e0c50a816040a033"
|
||||
integrity sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz#e55cf9b7c8c2204fdbb5d4818f8c5ba02aa49360"
|
||||
integrity sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==
|
||||
"@oxfmt/binding-linux-x64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz#48d3eeaf8e3757f638cf92de5ee4858befc9c0a3"
|
||||
integrity sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz#4998769ee1b5894efcd6cb99729d5a75f4c09dd1"
|
||||
integrity sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==
|
||||
"@oxfmt/binding-openharmony-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz#02be9e140ae35ba30f52bdce27612fece4a01ab3"
|
||||
integrity sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz#e09eaabdde76c885c4f8a190518c2eb2de08548a"
|
||||
integrity sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==
|
||||
"@oxfmt/binding-win32-arm64-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz#2226eaf52b6345a2cb926499216b2486cf0dbec2"
|
||||
integrity sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz#f36e306308923977365270d8b26290f4ca2fcfa5"
|
||||
integrity sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==
|
||||
"@oxfmt/binding-win32-ia32-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz#58d263bb5ecd7330c02f9dcd8cda10f66e42e74b"
|
||||
integrity sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz#bb6545e581d5ee7111084dbabeec7fe548bae418"
|
||||
integrity sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==
|
||||
"@oxfmt/binding-win32-x64-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz#02a166c8a8049c55d0096d1ba9d8e73f3a4d26a7"
|
||||
integrity sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==
|
||||
|
||||
"@parcel/watcher-android-arm64@2.5.6":
|
||||
version "2.5.6"
|
||||
@@ -3532,13 +3537,13 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.24.4"
|
||||
|
||||
"@rc-component/cascader@~1.17.0":
|
||||
version "1.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.17.0.tgz#52c0eceada2c7b4b37ebe822c19a6544b9562edf"
|
||||
integrity sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==
|
||||
"@rc-component/cascader@~1.22.0":
|
||||
version "1.22.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.22.0.tgz#eec0b6f4d2df5903aa12cfed321a578705926937"
|
||||
integrity sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
@@ -3614,14 +3619,14 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/image@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.9.0.tgz#110785d735d20336afcdbac84e8fbfd059a7a44e"
|
||||
integrity sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==
|
||||
"@rc-component/image@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.10.0.tgz#5d7a82d20e4c91f75875ea64eb1dadd7af676b1d"
|
||||
integrity sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.0.0"
|
||||
"@rc-component/portal" "^2.1.2"
|
||||
"@rc-component/util" "^1.10.1"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/input-number@~1.6.2":
|
||||
@@ -3633,7 +3638,7 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/input@~1.3.0", "@rc-component/input@~1.3.1":
|
||||
"@rc-component/input@~1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/input/-/input-1.3.1.tgz#230b8b59cdde8521d50f0eede63ddacb61cc0cd3"
|
||||
integrity sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==
|
||||
@@ -3642,15 +3647,27 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.10.0.tgz#46b1117cfb0c716b476e97f342555eccc2f41c97"
|
||||
integrity sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==
|
||||
"@rc-component/listy@~1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/listy/-/listy-1.2.3.tgz#e9c8ef4f409c231b44dded37e63ae2875bbe0334"
|
||||
integrity sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.0"
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/portal" "^2.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/util" "^1.3.1"
|
||||
"@rc-component/virtual-list" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.11.0.tgz#cee0c4710f26766ad8550d386cfec5ff86fd58d9"
|
||||
integrity sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/menu@~1.4.0", "@rc-component/menu@~1.4.1":
|
||||
@@ -3724,7 +3741,7 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
|
||||
"@rc-component/portal@^2.0.0", "@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/portal/-/portal-2.2.1.tgz#37c34b4c8cd73f53cc7072c96dd0e9ac332669ec"
|
||||
integrity sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==
|
||||
@@ -3772,10 +3789,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/select@~1.8.0", "@rc-component/select@~1.8.2":
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.8.2.tgz#f016992dae5c57186535512d73783e2fc7e4c59e"
|
||||
integrity sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==
|
||||
"@rc-component/select@~1.10.0":
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
|
||||
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
@@ -3807,10 +3824,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/table@~1.10.4":
|
||||
version "1.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.10.4.tgz#8c4e33bc150aa39f579c15426421348a789de326"
|
||||
integrity sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==
|
||||
"@rc-component/table@~1.11.0":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
|
||||
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
|
||||
dependencies:
|
||||
"@rc-component/context" "^2.0.1"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
@@ -3818,10 +3835,10 @@
|
||||
"@rc-component/virtual-list" "^1.0.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tabs@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.11.0.tgz#c157b2fadcdc2f3ab6c69d0098f73e03c6aa0c12"
|
||||
integrity sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==
|
||||
"@rc-component/tabs@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.12.0.tgz#41a1a77ed1afc4f1b8b727003a058c631aceea1b"
|
||||
integrity sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==
|
||||
dependencies:
|
||||
"@rc-component/dropdown" "~1.0.0"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
@@ -3830,13 +3847,13 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tooltip@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.4.0.tgz#c8cf15c6773218a5a36271467f06e663f99c28e7"
|
||||
integrity sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==
|
||||
"@rc-component/tooltip@~1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.0.tgz#422aa0760b310e0a1d0f9f7223e7f0d455de57a2"
|
||||
integrity sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==
|
||||
dependencies:
|
||||
"@rc-component/trigger" "^3.7.1"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/trigger" "^3.10.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tour@~2.4.0":
|
||||
@@ -3849,27 +3866,27 @@
|
||||
"@rc-component/util" "^1.7.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree-select@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.11.0.tgz#9080cdf1d28f2ddd6d8a4879b7aa90d3170f7db9"
|
||||
integrity sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==
|
||||
"@rc-component/tree-select@~1.16.0":
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
|
||||
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree@~1.3.2":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.3.2.tgz#4b0c13564314eff61ca948c18ef923b87c9d7e44"
|
||||
integrity sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==
|
||||
"@rc-component/tree@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.4.0.tgz#c0031180e681389bf0bdcb867a0087525b45c8a9"
|
||||
integrity sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.0.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
"@rc-component/virtual-list" "^1.2.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1":
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15":
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-3.10.1.tgz#cb28e1bc0745a2af6897dd7ec774f9b56dc88f86"
|
||||
integrity sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==
|
||||
@@ -3888,7 +3905,7 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f"
|
||||
integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==
|
||||
@@ -3906,6 +3923,16 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/virtual-list@^1.4.0":
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz#71c5844a8d6bd5b3501dfb66419d3a4612b2bb18"
|
||||
integrity sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^8.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.1"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@redocly/ajv@^8.18.1":
|
||||
version "8.18.3"
|
||||
resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.18.3.tgz#a925753d9a33375219f1b2ba91aef320f9929577"
|
||||
@@ -5658,110 +5685,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.66.0", "@typescript-eslint/eslint-plugin@^8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz#76e86aa5a2459fbf5bbd7a839c0dc0cce1d56224"
|
||||
integrity sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==
|
||||
"@typescript-eslint/eslint-plugin@8.67.0", "@typescript-eslint/eslint-plugin@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz#52f9f0e47d5a7571c4336e69bfeea581509ef2cf"
|
||||
integrity sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/type-utils" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/scope-manager" "8.67.0"
|
||||
"@typescript-eslint/type-utils" "8.67.0"
|
||||
"@typescript-eslint/utils" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.66.0", "@typescript-eslint/parser@^8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.66.0.tgz#88e3865ecf73b0118134e7cb831da87a961a57a1"
|
||||
integrity sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==
|
||||
"@typescript-eslint/parser@8.67.0", "@typescript-eslint/parser@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.67.0.tgz#0158022ec9927e0afcd58a8cc2ad57e01d892f5c"
|
||||
integrity sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/scope-manager" "8.67.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.66.0.tgz#828f788895df52d9eb2b543445a3a5a13e35ab4e"
|
||||
integrity sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==
|
||||
"@typescript-eslint/project-service@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.67.0.tgz#1552db007ca9206a1c6c7acf49e210bd17a8c56f"
|
||||
integrity sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.66.0"
|
||||
"@typescript-eslint/types" "^8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.67.0"
|
||||
"@typescript-eslint/types" "^8.67.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz#4fffcc6ebd0df9fe7983c0256967567ea6f5ac63"
|
||||
integrity sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==
|
||||
"@typescript-eslint/scope-manager@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz#4d4c2da09560d10dd7d947cba2d29d14d25af16d"
|
||||
integrity sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz#3a89066c507aa30541dc176804685b4b444e1e52"
|
||||
integrity sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@^8.66.0":
|
||||
"@typescript-eslint/tsconfig-utils@8.67.0", "@typescript-eslint/tsconfig-utils@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz#f45a3eba6b9132fb47141ec03ce2f275f1ea991d"
|
||||
integrity sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz#b2315303eca72fad9afa7be4f58f053c8f2a0479"
|
||||
integrity sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==
|
||||
"@typescript-eslint/type-utils@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz#96bed105275559df3bcf0449b73a6414d35c59ce"
|
||||
integrity sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
"@typescript-eslint/utils" "8.67.0"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.66.0.tgz#3cacab94d3b564c1d48c56eb37b89f89a6d48479"
|
||||
integrity sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==
|
||||
|
||||
"@typescript-eslint/types@^8.66.0":
|
||||
"@typescript-eslint/types@8.67.0", "@typescript-eslint/types@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.67.0.tgz#4a8d00cc1faba5c14feabc60f85b7a32652f34b6"
|
||||
integrity sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz#1a38c3a97dc6c669b66d585d7f90ebc4fbb32a50"
|
||||
integrity sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==
|
||||
"@typescript-eslint/typescript-estree@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz#116c3a47c06119c5a050e8851861d6497dd64bc2"
|
||||
integrity sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/project-service" "8.67.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.67.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.66.0.tgz#e277d67427043cdca2580ee91aa62921e4689969"
|
||||
integrity sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==
|
||||
"@typescript-eslint/utils@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.67.0.tgz#3e478a3d69d330a1fc50c12746cc2ee0732ccfcd"
|
||||
integrity sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/scope-manager" "8.67.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz#4c494e94745fb2724a4f37a310091e56b644d18a"
|
||||
integrity sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==
|
||||
"@typescript-eslint/visitor-keys@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz#601d40af9acf82a28da2286f3edafc69bba9017f"
|
||||
integrity sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -6164,10 +6181,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.5.4:
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.4.tgz#b41665e86a5f46ca761abd3b0abef7460116ca0d"
|
||||
integrity sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==
|
||||
antd@^6.6.0:
|
||||
version "6.6.0"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
|
||||
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6176,7 +6193,7 @@ antd@^6.5.4:
|
||||
"@ant-design/icons" "^6.3.2"
|
||||
"@ant-design/react-slick" "~2.0.0"
|
||||
"@babel/runtime" "^7.29.2"
|
||||
"@rc-component/cascader" "~1.17.0"
|
||||
"@rc-component/cascader" "~1.22.0"
|
||||
"@rc-component/checkbox" "~2.0.0"
|
||||
"@rc-component/collapse" "~1.2.0"
|
||||
"@rc-component/color-picker" "~3.1.1"
|
||||
@@ -6184,10 +6201,11 @@ antd@^6.5.4:
|
||||
"@rc-component/drawer" "~1.4.2"
|
||||
"@rc-component/dropdown" "~1.0.3"
|
||||
"@rc-component/form" "~1.8.6"
|
||||
"@rc-component/image" "~1.9.0"
|
||||
"@rc-component/image" "~1.10.0"
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/input-number" "~1.6.2"
|
||||
"@rc-component/mentions" "~1.10.0"
|
||||
"@rc-component/listy" "~1.2.3"
|
||||
"@rc-component/mentions" "~1.11.0"
|
||||
"@rc-component/menu" "~1.4.1"
|
||||
"@rc-component/motion" "^1.3.3"
|
||||
"@rc-component/mutate-observer" "^2.0.1"
|
||||
@@ -6199,16 +6217,16 @@ antd@^6.5.4:
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.2"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.8.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/slider" "~1.1.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.10.4"
|
||||
"@rc-component/tabs" "~1.11.0"
|
||||
"@rc-component/tooltip" "~1.4.0"
|
||||
"@rc-component/table" "~1.11.0"
|
||||
"@rc-component/tabs" "~1.12.0"
|
||||
"@rc-component/tooltip" "~1.5.0"
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/tree-select" "~1.11.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/tree-select" "~1.16.0"
|
||||
"@rc-component/trigger" "^3.10.1"
|
||||
"@rc-component/upload" "~1.1.1"
|
||||
"@rc-component/util" "^1.12.0"
|
||||
@@ -6504,10 +6522,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.12"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz#42ac48770bf73d292f60ce8ba4dc5e7ebb242ec3"
|
||||
integrity sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.13"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
|
||||
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -6745,10 +6763,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001807:
|
||||
version "1.0.30001807"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz#a113854941fb45b4c1f51793f4636920489079b4"
|
||||
integrity sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001809:
|
||||
version "1.0.30001809"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz#e6cf71f14ddfe008f114dd2a846923be3c03a07b"
|
||||
integrity sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -12244,32 +12262,32 @@ oxc-resolver@^11.19.1:
|
||||
"@oxc-resolver/binding-win32-arm64-msvc" "11.23.0"
|
||||
"@oxc-resolver/binding-win32-x64-msvc" "11.23.0"
|
||||
|
||||
oxfmt@^0.62.0:
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.62.0.tgz#9945728022d26dc0a1d5bc486db112e7e340507a"
|
||||
integrity sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==
|
||||
oxfmt@^0.63.0:
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.63.0.tgz#c7338e6c43a68d5cf8dc61c08b617d77cb54e323"
|
||||
integrity sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==
|
||||
dependencies:
|
||||
tinypool "2.1.0"
|
||||
optionalDependencies:
|
||||
"@oxfmt/binding-android-arm-eabi" "0.62.0"
|
||||
"@oxfmt/binding-android-arm64" "0.62.0"
|
||||
"@oxfmt/binding-darwin-arm64" "0.62.0"
|
||||
"@oxfmt/binding-darwin-x64" "0.62.0"
|
||||
"@oxfmt/binding-freebsd-x64" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm-gnueabihf" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm-musleabihf" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm64-musl" "0.62.0"
|
||||
"@oxfmt/binding-linux-ppc64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-riscv64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-riscv64-musl" "0.62.0"
|
||||
"@oxfmt/binding-linux-s390x-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-x64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-x64-musl" "0.62.0"
|
||||
"@oxfmt/binding-openharmony-arm64" "0.62.0"
|
||||
"@oxfmt/binding-win32-arm64-msvc" "0.62.0"
|
||||
"@oxfmt/binding-win32-ia32-msvc" "0.62.0"
|
||||
"@oxfmt/binding-win32-x64-msvc" "0.62.0"
|
||||
"@oxfmt/binding-android-arm-eabi" "0.63.0"
|
||||
"@oxfmt/binding-android-arm64" "0.63.0"
|
||||
"@oxfmt/binding-darwin-arm64" "0.63.0"
|
||||
"@oxfmt/binding-darwin-x64" "0.63.0"
|
||||
"@oxfmt/binding-freebsd-x64" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm-gnueabihf" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm-musleabihf" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm64-musl" "0.63.0"
|
||||
"@oxfmt/binding-linux-ppc64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-riscv64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-riscv64-musl" "0.63.0"
|
||||
"@oxfmt/binding-linux-s390x-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-x64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-x64-musl" "0.63.0"
|
||||
"@oxfmt/binding-openharmony-arm64" "0.63.0"
|
||||
"@oxfmt/binding-win32-arm64-msvc" "0.63.0"
|
||||
"@oxfmt/binding-win32-ia32-msvc" "0.63.0"
|
||||
"@oxfmt/binding-win32-x64-msvc" "0.63.0"
|
||||
|
||||
p-cancelable@^3.0.0:
|
||||
version "3.0.0"
|
||||
@@ -15467,15 +15485,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.66.0:
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.66.0.tgz#0809b6d25c8a0924690ba30dc1f05607093c11fb"
|
||||
integrity sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==
|
||||
typescript-eslint@^8.67.0:
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.67.0.tgz#1e92de09ee0ff2d96cc0848f5e9f345ea930d963"
|
||||
integrity sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.66.0"
|
||||
"@typescript-eslint/parser" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.67.0"
|
||||
"@typescript-eslint/parser" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
"@typescript-eslint/utils" "8.67.0"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
import isodate
|
||||
import pyarrow as pa
|
||||
@@ -90,6 +91,8 @@ class Dimension:
|
||||
definition: str | None = None
|
||||
description: str | None = None
|
||||
grain: Grain | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
@@ -121,6 +124,9 @@ class Metric:
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
d3format: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pyarrow as pa
|
||||
from superset_core.semantic_layers.types import Dimension, Metric
|
||||
|
||||
|
||||
def test_dimension_metadata_is_not_part_of_identity() -> None:
|
||||
first = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
metadata={"display_name": "Region"},
|
||||
)
|
||||
second = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Sales region",
|
||||
metadata={"display_name": "Sales region"},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_metadata_is_not_part_of_identity() -> None:
|
||||
first = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
metadata={"unit": {"kind": "currency", "code": "USD"}},
|
||||
)
|
||||
second = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Revenue",
|
||||
d3format=",.0f",
|
||||
metadata={"unit": {"kind": "currency", "code": "EUR"}},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_accepts_superset_presentation_fields() -> None:
|
||||
metric = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
)
|
||||
|
||||
assert metric.verbose_name == "Total revenue"
|
||||
assert metric.d3format == "$,.2f"
|
||||
|
||||
|
||||
def test_dimension_accepts_superset_presentation_fields() -> None:
|
||||
dimension = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
)
|
||||
|
||||
assert dimension.verbose_name == "Region"
|
||||
|
||||
|
||||
def test_metadata_defaults_are_not_shared() -> None:
|
||||
first = Metric("first", "first", pa.int64(), "COUNT(*)")
|
||||
second = Metric("second", "second", pa.int64(), "COUNT(*)")
|
||||
|
||||
first.metadata["display_name"] = "First"
|
||||
|
||||
assert second.metadata == {}
|
||||
Generated
+329
-618
File diff suppressed because it is too large
Load Diff
@@ -158,7 +158,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.4",
|
||||
"antd": "^6.6.0",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -176,7 +176,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"google-auth-library": "^11.0.1",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -266,7 +266,7 @@
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
@@ -277,7 +277,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -289,19 +289,19 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -331,8 +331,8 @@
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.77.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -349,7 +349,7 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.10",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
+6
-4
@@ -107,12 +107,14 @@ const getAllSelectOptions = () =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const findAllSelectOptions = () =>
|
||||
@@ -644,7 +646,7 @@ test('does not add a new option if the option already exists', async () => {
|
||||
await type(option);
|
||||
await waitFor(() => {
|
||||
const array = within(
|
||||
getElementByClassName('.rc-virtual-list'),
|
||||
getElementByClassName('.ant-select-dropdown-list'),
|
||||
).getAllByText(option);
|
||||
expect(array.length).toBe(1);
|
||||
});
|
||||
@@ -1398,7 +1400,7 @@ test('appends page>1 results during an active search and discards them when sear
|
||||
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
|
||||
// then forwards to onPopupScroll (handlePagination here).
|
||||
const holder = document.querySelector(
|
||||
'.rc-virtual-list-holder',
|
||||
'.ant-select-dropdown-list-holder',
|
||||
) as HTMLElement | null;
|
||||
if (!holder) throw new Error('virtual-list holder not rendered');
|
||||
Object.defineProperty(holder, 'scrollHeight', {
|
||||
|
||||
@@ -93,12 +93,14 @@ const deselectAllButtonText = (length: number) =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const getAllSelectOptions = () =>
|
||||
|
||||
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
|
||||
| 'onOpenChange'
|
||||
| 'optionRender'
|
||||
| 'placeholder'
|
||||
| 'prefix'
|
||||
| 'showArrow'
|
||||
| 'showSearch'
|
||||
| 'tokenSeparators'
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getSequentialSchemeRegistry } from '@superset-ui/core';
|
||||
import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import CalHeatMapImport from './vendor/cal-heatmap';
|
||||
import { convertUTCTimestampToLocal } from './utils';
|
||||
import { convertUTCTimestampToLocal, getFormattedUTCTime } from './utils';
|
||||
|
||||
// The vendor file is @ts-nocheck, so its export lacks type info.
|
||||
// Define a minimal constructor interface for use in this file.
|
||||
@@ -103,6 +103,8 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
const subDomainTextFormat = showValues
|
||||
? (_date: Date, value: number) => valueFormatter(value)
|
||||
: null;
|
||||
const dateFormatter = (date: Date, format: string) =>
|
||||
getFormattedUTCTime(date.getTime(), format);
|
||||
|
||||
const metricsData = data.data;
|
||||
|
||||
@@ -166,6 +168,7 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
itemName: '',
|
||||
valueFormatter,
|
||||
timeFormatter,
|
||||
dateFormatter,
|
||||
subDomainTextFormat,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,8 @@ var CalHeatMap = function () {
|
||||
|
||||
timeFormatter: d => d,
|
||||
|
||||
dateFormatter: null,
|
||||
|
||||
domain: 'hour',
|
||||
|
||||
subDomain: 'min',
|
||||
@@ -1990,10 +1992,14 @@ CalHeatMap.prototype = {
|
||||
|
||||
if (typeof format === 'function') {
|
||||
return format(d);
|
||||
} else {
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
}
|
||||
|
||||
if (typeof this.options.dateFormatter === 'function') {
|
||||
return this.options.dateFormatter(d, format);
|
||||
}
|
||||
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
},
|
||||
|
||||
getSubDomainTitle: function (d) {
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
|
||||
import { convertUTCTimestampToLocal } from '../src/utils';
|
||||
|
||||
interface MockCalHeatMapConfig {
|
||||
itemSelector: Element;
|
||||
dateFormatter?: (date: Date, format: string) => string;
|
||||
}
|
||||
|
||||
type MetricNameInput = string | string[];
|
||||
@@ -38,6 +40,7 @@ let mockInitCallCount = 0;
|
||||
let mockThrowOnInitCall: number | null = null;
|
||||
let mockDestroyCallCount = 0;
|
||||
let mockDestroyedInstanceIds: string[] = [];
|
||||
let mockDateFormatter: MockCalHeatMapConfig['dateFormatter'];
|
||||
|
||||
const mockTheme = {
|
||||
colorBgElevated: '#ffffff',
|
||||
@@ -56,6 +59,7 @@ jest.mock('../src/vendor/cal-heatmap', () => ({
|
||||
} = require('../src/tooltip');
|
||||
|
||||
mockInitCallCount += 1;
|
||||
mockDateFormatter = config.dateFormatter;
|
||||
if (mockThrowOnInitCall === mockInitCallCount) {
|
||||
throw new Error('Mock CalHeatMap init failure');
|
||||
}
|
||||
@@ -284,9 +288,28 @@ afterEach(() => {
|
||||
mockThrowOnInitCall = null;
|
||||
mockDestroyCallCount = 0;
|
||||
mockDestroyedInstanceIds = [];
|
||||
mockDateFormatter = undefined;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('Calendar provides a timezone-safe date formatter to CalHeatMap', () => {
|
||||
const calendarOwner = document.createElement('div');
|
||||
document.body.appendChild(calendarOwner);
|
||||
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps('localized-metric'),
|
||||
theme: mockTheme,
|
||||
});
|
||||
|
||||
if (!mockDateFormatter) {
|
||||
throw new Error('Expected Calendar to configure a date formatter');
|
||||
}
|
||||
|
||||
const localDate = new Date(convertUTCTimestampToLocal(Date.UTC(2024, 0, 1)));
|
||||
|
||||
expect(mockDateFormatter(localDate, '%Y-%m-%d')).toBe('2024-01-01');
|
||||
});
|
||||
|
||||
test('rerender and unmount clean up only the affected calendar tooltips', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import CalHeatMapImport from '../src/vendor/cal-heatmap';
|
||||
|
||||
type DateFormatter = (date: Date, format: string) => string;
|
||||
type FunctionalDateFormat = (date: Date) => string;
|
||||
|
||||
interface CalHeatMapInstance {
|
||||
options: {
|
||||
dateFormatter: DateFormatter | null;
|
||||
};
|
||||
formatDate(date: Date, format: string | FunctionalDateFormat): string;
|
||||
}
|
||||
|
||||
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
|
||||
|
||||
test('CalHeatMap delegates string date formats to the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'Январь');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('Январь');
|
||||
expect(dateFormatter).toHaveBeenCalledWith(date, '%B');
|
||||
});
|
||||
|
||||
test('CalHeatMap preserves functional formatters over the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'localized');
|
||||
const functionalFormat = jest.fn<string, [Date]>(() => 'custom');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, functionalFormat)).toBe('custom');
|
||||
expect(functionalFormat).toHaveBeenCalledWith(date);
|
||||
expect(dateFormatter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('CalHeatMap keeps the D3 formatter fallback', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('January');
|
||||
});
|
||||
@@ -16,9 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import Echart, { isReportScreenshotMode } from './Echart';
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import Echart, {
|
||||
ECHARTS_HOST_CLASS,
|
||||
ECHARTS_RENDER_FINISHED_CLASS,
|
||||
isReportScreenshotMode,
|
||||
} from './Echart';
|
||||
import type { EchartsProps } from '../types';
|
||||
|
||||
type Handler = (params: unknown) => void;
|
||||
@@ -272,3 +276,31 @@ test('keeps animation enabled when not in report screenshot mode', async () => {
|
||||
const lastOptions = mockChart.setOption.mock.calls.at(-1)?.[0];
|
||||
expect(lastOptions.animation).not.toBe(false);
|
||||
});
|
||||
|
||||
test('tags the ECharts canvas host with the readiness-gate class', async () => {
|
||||
const { container } = render(renderEchart(), {
|
||||
initialState,
|
||||
useRedux: true,
|
||||
});
|
||||
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
|
||||
expect(container.querySelector(`.${ECHARTS_HOST_CLASS}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('marks the host painted only on the ECharts `finished` event', async () => {
|
||||
const { container } = render(renderEchart(), {
|
||||
initialState,
|
||||
useRedux: true,
|
||||
});
|
||||
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
|
||||
|
||||
const host = container.querySelector(`.${ECHARTS_HOST_CLASS}`) as HTMLElement;
|
||||
expect(host).not.toBeNull();
|
||||
|
||||
// `setOption` ran during mount, which clears the marker; `finished` has not
|
||||
// fired yet, so the host must NOT be flagged as painted.
|
||||
expect(host).not.toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
|
||||
// Simulate ECharts completing its draw -> the host is flagged painted.
|
||||
trigger('finished');
|
||||
expect(host).toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
});
|
||||
|
||||
@@ -138,6 +138,15 @@ export function isReportScreenshotMode(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Report-screenshot readiness contract (see superset/utils/screenshot_utils.py).
|
||||
// `echarts-host` marks the canvas host element; `echarts-render-finished` is
|
||||
// toggled OFF before each setOption and ON in the ECharts `finished` event --
|
||||
// the only signal that the canvas is fully painted (chartStatus/onRenderSuccess
|
||||
// both fire pre-paint). The readiness gate treats a host that lacks
|
||||
// `echarts-render-finished` as not-yet-painted so it never captures a blank chart.
|
||||
export const ECHARTS_HOST_CLASS = 'echarts-host';
|
||||
export const ECHARTS_RENDER_FINISHED_CLASS = 'echarts-render-finished';
|
||||
|
||||
function Echart(
|
||||
{
|
||||
width,
|
||||
@@ -201,6 +210,11 @@ function Echart(
|
||||
width,
|
||||
height,
|
||||
});
|
||||
// Paint marker for the report-screenshot readiness gate. `finished`
|
||||
// is the only event that guarantees the canvas is fully drawn.
|
||||
chartRef.current.on('finished', () => {
|
||||
divRef.current?.classList.add(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
});
|
||||
}
|
||||
// did mount
|
||||
handleSizeChange({ width, height });
|
||||
@@ -321,6 +335,9 @@ function Echart(
|
||||
}
|
||||
)?.dataZoom
|
||||
: undefined;
|
||||
// Clear the paint marker before (re)drawing; the `finished` handler
|
||||
// re-adds it once the new frame is fully rendered.
|
||||
divRef.current?.classList.remove(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
replaceMerge: notMerge ? undefined : ['series'],
|
||||
@@ -412,7 +429,14 @@ function Echart(
|
||||
handleSizeChange({ width, height });
|
||||
}, [width, height, handleSizeChange]);
|
||||
|
||||
return <Styles ref={divRef} height={height} width={width} />;
|
||||
return (
|
||||
<Styles
|
||||
ref={divRef}
|
||||
className={ECHARTS_HOST_CLASS}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Echart);
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function selectOption(option: string, selectName?: string) {
|
||||
const item = await waitFor(() =>
|
||||
within(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
document.querySelector('.rc-virtual-list')!,
|
||||
document.querySelector('.ant-select-dropdown-list')!,
|
||||
).getByText(option),
|
||||
);
|
||||
await userEvent.click(item);
|
||||
|
||||
+1
-1
@@ -867,7 +867,7 @@ function DatasourceEditor({
|
||||
return {
|
||||
...metric,
|
||||
certification_details: certificationDetails || details,
|
||||
warning_markdown: warningMarkdown || '',
|
||||
warning_markdown: warningMarkdown || metric.warning_markdown || '',
|
||||
certified_by: certifiedBy || certifiedByMetric,
|
||||
};
|
||||
}),
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
// Certifying a metric fills two adjacent fields in one visit to the expanded
|
||||
// row. Both are committed through TextControl's debounce, so the second one
|
||||
// used to land on the item as it looked before the first had been applied,
|
||||
// leaving the saved metric with details but no certifier.
|
||||
test('certifying a metric keeps both certified_by and certification_details', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Certified by'),
|
||||
'Metric Certifier',
|
||||
);
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Certification details'),
|
||||
'Metric cert details',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const { calls } = testProps.onChange.mock;
|
||||
const savedMetrics = calls[calls.length - 1]?.[0]?.metrics ?? [];
|
||||
const saved = savedMetrics.find(metric => metric.metric_name === 'count');
|
||||
expect(saved).toEqual(
|
||||
expect.objectContaining({
|
||||
certified_by: 'Metric Certifier',
|
||||
certification_details: 'Metric cert details',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
// Stub the Ace-backed control with a plain textarea. Ace spreads its document
|
||||
// across many spans and keeps only the keystroke buffer in its own textarea,
|
||||
// so asserting on the value the control receives is less brittle than
|
||||
// reaching into Ace's DOM.
|
||||
jest.mock('src/explore/components/controls/TextAreaControl', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
controlId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
controlId?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-test={`mock-textarea-${controlId}`}
|
||||
value={value ?? ''}
|
||||
onChange={event => onChange?.(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
// Regression test for #42704. Explore's datasource payload (SqlMetric.data on
|
||||
// the backend) exposes warning_markdown as a flattened top-level field and
|
||||
// omits the raw `extra` JSON string that the /api/v1/dataset/{id} endpoint
|
||||
// backing the Datasets page provides. Deriving warning_markdown purely from
|
||||
// `extra` therefore dropped the saved text when the modal was opened from
|
||||
// Explore, leaving the Warning field blank on reopen.
|
||||
test('keeps a pre-existing top-level warning_markdown when the metric has no extra', async () => {
|
||||
const baseProps = createProps();
|
||||
const testProps = {
|
||||
...baseProps,
|
||||
datasource: {
|
||||
...baseProps.datasource,
|
||||
metrics: [
|
||||
{
|
||||
...baseProps.datasource.metrics[0],
|
||||
warning_markdown: 'existing warning',
|
||||
extra: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
const metricsTab = await screen.findByTestId('collection-tab-Metrics');
|
||||
await userEvent.click(metricsTab);
|
||||
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('mock-textarea-warning_markdown'),
|
||||
).toHaveValue('existing warning');
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { Divider, Form, Typography } from '@superset-ui/core/components';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { recurseReactClone } from '../../utils';
|
||||
@@ -39,14 +39,24 @@ export default function Fieldset({
|
||||
title = null,
|
||||
compact = false,
|
||||
}: FieldsetProps) {
|
||||
// Controls report their edits asynchronously - TextControl debounces by
|
||||
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
|
||||
// earlier render. Spreading that render's `item` rebuilds the whole record
|
||||
// from a snapshot taken before a sibling field committed, dropping the value
|
||||
// the user typed first. Reading off a ref merges into the latest commit.
|
||||
const itemRef = useRef(item);
|
||||
useEffect(() => {
|
||||
itemRef.current = item;
|
||||
}, [item]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(fieldKey: fieldKeyType, val: any) => {
|
||||
onChange?.({
|
||||
...item,
|
||||
...itemRef.current,
|
||||
[fieldKey]: val,
|
||||
});
|
||||
},
|
||||
[onChange, item],
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
|
||||
|
||||
@@ -135,6 +135,15 @@ describe('dashboardState actions', () => {
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('saveDashboardRequest', () => {
|
||||
const findDangerToast = (dispatch: jest.Mock) =>
|
||||
dispatch.mock.calls
|
||||
.map(call => call[0])
|
||||
.find(
|
||||
action =>
|
||||
action?.type === ADD_TOAST &&
|
||||
action.payload.toastType === ToastType.Danger,
|
||||
);
|
||||
|
||||
test('should dispatch UPDATE_COMPONENTS_PARENTS_LIST action', () => {
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardState: { hasUnsavedChanges: false },
|
||||
@@ -227,6 +236,89 @@ describe('dashboardState actions', () => {
|
||||
const { body } = putStub.mock.calls[0][0];
|
||||
expect(body).toBe(JSON.stringify(confirmedDashboardData));
|
||||
});
|
||||
|
||||
test('warns about the overwrite values when a diff is detected', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toBe(
|
||||
'Please confirm the overwrite values.',
|
||||
),
|
||||
);
|
||||
expect(putStub.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('reports the actual error when the overwrite precheck fails', async () => {
|
||||
getStub.mockRestore();
|
||||
getStub = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('precheck exploded'));
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toContain(
|
||||
'precheck exploded',
|
||||
),
|
||||
);
|
||||
expect(putStub.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('when FeatureFlag.CONFIRM_DASHBOARD_DIFF is disabled', () => {
|
||||
beforeEach(() => {
|
||||
mockIsFeatureEnabled.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsFeatureEnabled.mockRestore();
|
||||
});
|
||||
|
||||
test('never runs the overwrite precheck', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
|
||||
expect(getStub).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: '/api/v1/dashboard/192' }),
|
||||
);
|
||||
});
|
||||
|
||||
// An unexpected failure used to reach the overwrite-confirm handler,
|
||||
// which reported it as "Please confirm the overwrite values." even with
|
||||
// the feature flag off, hiding the real error.
|
||||
test('reports the actual error when the update throws unexpectedly', async () => {
|
||||
putStub.mockRestore();
|
||||
putStub = jest.spyOn(SupersetClient, 'put').mockImplementation(() => {
|
||||
throw new Error('unexpected boom');
|
||||
});
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toContain(
|
||||
'unexpected boom',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should navigate to the new dashboard after Save As', async () => {
|
||||
@@ -379,15 +471,6 @@ describe('dashboardState actions', () => {
|
||||
// permission-denied copy, while a 403 from outside Superset (reverse proxy,
|
||||
// WAF, SSO gateway) carries a non-JSON body and must fall back to the
|
||||
// generic status-derived toast. See #42239.
|
||||
const findDangerToast = (dispatch: jest.Mock) =>
|
||||
dispatch.mock.calls
|
||||
.map(call => call[0])
|
||||
.find(
|
||||
action =>
|
||||
action?.type === ADD_TOAST &&
|
||||
action.payload.toastType === ToastType.Danger,
|
||||
);
|
||||
|
||||
test('maps a non-JSON 403 save failure to the generic error toast', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
putStub.mockRestore();
|
||||
|
||||
@@ -646,6 +646,7 @@ export function saveDashboardRequest(
|
||||
};
|
||||
|
||||
const onError = async (response: Response): Promise<void> => {
|
||||
logging.error(response);
|
||||
const { error, message } = await getClientErrorObject(response);
|
||||
let errorText = t('Sorry, an unknown error occurred');
|
||||
|
||||
@@ -689,64 +690,64 @@ export function saveDashboardRequest(
|
||||
}),
|
||||
};
|
||||
|
||||
const updateDashboard = (): Promise<JsonObject | void> =>
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedDashboard),
|
||||
})
|
||||
.then(response => onUpdateSuccess(response))
|
||||
.catch(response => onError(response));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (
|
||||
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
|
||||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
|
||||
) {
|
||||
// skip overwrite precheck
|
||||
resolve();
|
||||
return;
|
||||
const updateDashboard = async (): Promise<JsonObject | void> => {
|
||||
try {
|
||||
const response = await SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedDashboard),
|
||||
});
|
||||
return await onUpdateSuccess(response);
|
||||
} catch (error) {
|
||||
return onError(error as Response);
|
||||
}
|
||||
};
|
||||
|
||||
// precheck for overwrite items
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
}).then((response: JsonObject) => {
|
||||
if (
|
||||
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
|
||||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
|
||||
) {
|
||||
// skip overwrite precheck
|
||||
return updateDashboard();
|
||||
}
|
||||
|
||||
// precheck for overwrite items
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
})
|
||||
.then((response: JsonObject) => {
|
||||
const dashboard = (response.json as JsonObject).result as JsonObject;
|
||||
const overwriteConfirmItems = getOverwriteItems(
|
||||
dashboard,
|
||||
updatedDashboard,
|
||||
);
|
||||
if (overwriteConfirmItems.length > 0) {
|
||||
dispatch(
|
||||
setOverrideConfirm({
|
||||
updatedAt: dashboard.changed_on as string,
|
||||
updatedBy: dashboard.changed_by_name as string,
|
||||
overwriteConfirmItems:
|
||||
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
|
||||
| { overwriteConfirmItems: infer I }
|
||||
| undefined
|
||||
? I
|
||||
: never,
|
||||
dashboardId: id,
|
||||
data: updatedDashboard,
|
||||
}),
|
||||
);
|
||||
return reject(overwriteConfirmItems);
|
||||
if (overwriteConfirmItems.length === 0) {
|
||||
return updateDashboard();
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
})
|
||||
.then(updateDashboard)
|
||||
.catch((overwriteConfirmItems: JsonObject[]) => {
|
||||
const errorText = t('Please confirm the overwrite values.');
|
||||
dispatch(
|
||||
setOverrideConfirm({
|
||||
updatedAt: dashboard.changed_on as string,
|
||||
updatedBy: dashboard.changed_by_name as string,
|
||||
overwriteConfirmItems:
|
||||
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
|
||||
| { overwriteConfirmItems: infer I }
|
||||
| undefined
|
||||
? I
|
||||
: never,
|
||||
dashboardId: id,
|
||||
data: updatedDashboard,
|
||||
}),
|
||||
);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, {
|
||||
dashboard_id: id,
|
||||
items: overwriteConfirmItems,
|
||||
}),
|
||||
);
|
||||
dispatch(addDangerToast(errorText));
|
||||
});
|
||||
dispatch(addDangerToast(t('Please confirm the overwrite values.')));
|
||||
return undefined;
|
||||
})
|
||||
.catch(onError);
|
||||
}
|
||||
// changing the data as the endpoint requires
|
||||
if (
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { Dispatch } from 'redux';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { cloneDeep, omit } from 'lodash-es';
|
||||
import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions';
|
||||
import { HYDRATE_DASHBOARD } from './hydrate';
|
||||
import {
|
||||
@@ -90,12 +90,20 @@ export const setFilterConfiguration =
|
||||
});
|
||||
try {
|
||||
const response = await updateFilters(filterChanges);
|
||||
// chartsInScope/tabsInScope are derived from the live layout, and the
|
||||
// response carries the persisted copy for every filter - including the
|
||||
// ones this save never touched, whose copy is whatever was stored when
|
||||
// the dashboard was last saved. Dropping them lets the reducers keep the
|
||||
// scopes calculateScopes already computed for this session.
|
||||
const savedFilters = response.result.map(
|
||||
filter => omit(filter, ['chartsInScope', 'tabsInScope']) as Filter,
|
||||
);
|
||||
dispatch({
|
||||
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
|
||||
filterChanges: response.result,
|
||||
filterChanges: savedFilters,
|
||||
deletedIds: filterChanges.deleted,
|
||||
});
|
||||
dispatch(nativeFiltersConfigChanged(response.result));
|
||||
dispatch(nativeFiltersConfigChanged(savedFilters));
|
||||
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ const typeIntoSelect = async (text: string) => {
|
||||
const findOption = (text: string) =>
|
||||
waitFor(() => {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const virtualList = document.querySelector('.rc-virtual-list');
|
||||
const virtualList = document.querySelector('.ant-select-dropdown-list');
|
||||
if (!virtualList) {
|
||||
throw new Error('Virtual list not found');
|
||||
}
|
||||
|
||||
+9
-4
@@ -68,6 +68,7 @@ export const TableControls = ({
|
||||
canDownload,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -111,14 +112,18 @@ export const TableControls = ({
|
||||
value={rowLimit}
|
||||
onChange={onRowLimitChange}
|
||||
options={rowLimitOptions ?? []}
|
||||
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
|
||||
prefix={t('Limit')}
|
||||
css={css`
|
||||
min-width: 110px;
|
||||
min-width: 160px;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
)}
|
||||
<RowCountLabel
|
||||
rowcount={rowcount}
|
||||
limit={effectiveRowLimit ?? rowLimit}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{canDownload && onDownloadCSV && onDownloadXLSX && (
|
||||
<DownloadDropdown
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
|
||||
+2
@@ -56,6 +56,7 @@ export const SingleQueryResultPane = ({
|
||||
columnDisplayNames,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -86,6 +87,7 @@ export const SingleQueryResultPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={rowLimitOptions}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
onRowLimitChange={onRowLimitChange}
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
onDownloadXLSX={onDownloadXLSX}
|
||||
|
||||
@@ -236,6 +236,7 @@ export const useResultsPane = ({
|
||||
columnDisplayNames={columnDisplayNames}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
</StyledDiv>
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
TableControls,
|
||||
ROW_LIMIT_OPTIONS,
|
||||
} from '../components/DataTableControls';
|
||||
import { TableControlsProps } from '../types';
|
||||
|
||||
const setup = (overrides: Partial<TableControlsProps> = {}) =>
|
||||
render(
|
||||
<TableControls
|
||||
data={[]}
|
||||
columnNames={['name']}
|
||||
columnTypes={[GenericDataType.String]}
|
||||
rowcount={0}
|
||||
onInputChange={jest.fn()}
|
||||
isLoading={false}
|
||||
canDownload
|
||||
rowLimit={100}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={jest.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
test('shows the row count when the result fills the selected row limit', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
|
||||
});
|
||||
|
||||
test('warns that the row limit was reached when the result fills it', async () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not warn when the result is smaller than the selected row limit', () => {
|
||||
setup({ rowcount: 42, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
|
||||
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('labels the row limit selector so it is not read as a second row count', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByText('Limit')).toBeInTheDocument();
|
||||
});
|
||||
@@ -84,6 +84,9 @@ export interface TableControlsProps extends DrillControlsProps {
|
||||
canDownload: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
// Effective result limit, capped by the chart's row limit.
|
||||
// Defaults to `rowLimit` and controls the "row limit reached" warning.
|
||||
effectiveRowLimit?: number;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -104,5 +107,6 @@ export interface SingleQueryResultPaneProp
|
||||
columnDisplayNames?: Record<string, string>;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
effectiveRowLimit?: number;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
+10
-6
@@ -214,7 +214,9 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -226,7 +228,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Identifier');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('User Identifier')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Creation Date')).not.toBeInTheDocument();
|
||||
@@ -234,7 +236,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, '_at');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Creation Date')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Last Update')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
@@ -288,7 +290,9 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Net Profit')).not.toBeInTheDocument();
|
||||
@@ -298,7 +302,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Rate');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Discount Rate')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
|
||||
@@ -306,7 +310,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'profit');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Net Profit')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Profit Margin')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
|
||||
+10
-6
@@ -340,7 +340,9 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Gross Revenue')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Average Price')).not.toBeInTheDocument();
|
||||
@@ -352,7 +354,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Unique');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Unique Users')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
|
||||
@@ -360,7 +362,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'total');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Count')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Total Quantity')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
|
||||
@@ -421,7 +423,9 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
|
||||
await userEvent.type(columnCombobox, 'product');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Product Title')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -435,7 +439,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
await userEvent.clear(columnCombobox);
|
||||
await userEvent.type(columnCombobox, 'Modified');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -445,7 +449,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
await userEvent.clear(columnCombobox);
|
||||
await userEvent.type(columnCombobox, '_at');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Creation Timestamp')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -88,9 +88,9 @@ test('PermissionsField shows a permission matched by its raw name even though th
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await within(document.querySelector('.rc-virtual-list')!).findByText(
|
||||
'stg silver',
|
||||
),
|
||||
await within(
|
||||
document.querySelector('.ant-select-dropdown-list')!,
|
||||
).findByText('stg silver'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Generated
+310
-155
@@ -15,24 +15,24 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.2"
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^10.8.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
@@ -310,9 +310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-android-arm-eabi": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz",
|
||||
"integrity": "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz",
|
||||
"integrity": "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -327,9 +327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-android-arm64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz",
|
||||
"integrity": "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz",
|
||||
"integrity": "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -344,9 +344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-darwin-arm64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz",
|
||||
"integrity": "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz",
|
||||
"integrity": "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -361,9 +361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-darwin-x64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz",
|
||||
"integrity": "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz",
|
||||
"integrity": "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -378,9 +378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-freebsd-x64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz",
|
||||
"integrity": "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz",
|
||||
"integrity": "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -395,9 +395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz",
|
||||
"integrity": "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz",
|
||||
"integrity": "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -412,9 +412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm-musleabihf": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz",
|
||||
"integrity": "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz",
|
||||
"integrity": "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -429,9 +429,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -449,9 +449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm64-musl": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz",
|
||||
"integrity": "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz",
|
||||
"integrity": "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -469,9 +469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-ppc64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -489,9 +489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-riscv64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -509,9 +509,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-riscv64-musl": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz",
|
||||
"integrity": "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz",
|
||||
"integrity": "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -529,9 +529,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-s390x-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -549,9 +549,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-x64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -569,9 +569,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-x64-musl": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz",
|
||||
"integrity": "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz",
|
||||
"integrity": "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -589,9 +589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-openharmony-arm64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz",
|
||||
"integrity": "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz",
|
||||
"integrity": "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -606,9 +606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-win32-arm64-msvc": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz",
|
||||
"integrity": "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz",
|
||||
"integrity": "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -623,9 +623,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-win32-ia32-msvc": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz",
|
||||
"integrity": "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz",
|
||||
"integrity": "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -640,9 +640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-win32-x64-msvc": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz",
|
||||
"integrity": "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz",
|
||||
"integrity": "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1044,9 +1044,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1070,17 +1070,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
|
||||
"integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
||||
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/type-utils": "8.66.0",
|
||||
"@typescript-eslint/utils": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/type-utils": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -1093,22 +1093,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
|
||||
"integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1123,15 +1123,145 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
|
||||
"integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
|
||||
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.66.0",
|
||||
"@typescript-eslint/types": "^8.66.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.67.0",
|
||||
"@typescript-eslint/types": "^8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
|
||||
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
|
||||
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
|
||||
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.67.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
|
||||
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
|
||||
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.67.0",
|
||||
"@typescript-eslint/types": "^8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1146,14 +1276,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
|
||||
"integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
|
||||
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0"
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1164,9 +1294,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
|
||||
"integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1181,15 +1311,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz",
|
||||
"integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0",
|
||||
"@typescript-eslint/utils": "8.66.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1206,9 +1336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
|
||||
"integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
|
||||
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1220,16 +1350,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
|
||||
"integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
|
||||
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.66.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0",
|
||||
"@typescript-eslint/project-service": "8.67.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1248,16 +1378,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
|
||||
"integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
|
||||
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0"
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1272,13 +1402,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
|
||||
"integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
|
||||
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1689,9 +1819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
|
||||
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
|
||||
"version": "10.8.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -2709,9 +2839,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxfmt": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.62.0.tgz",
|
||||
"integrity": "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.63.0.tgz",
|
||||
"integrity": "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2727,25 +2857,25 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxfmt/binding-android-arm-eabi": "0.62.0",
|
||||
"@oxfmt/binding-android-arm64": "0.62.0",
|
||||
"@oxfmt/binding-darwin-arm64": "0.62.0",
|
||||
"@oxfmt/binding-darwin-x64": "0.62.0",
|
||||
"@oxfmt/binding-freebsd-x64": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm-musleabihf": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm64-musl": "0.62.0",
|
||||
"@oxfmt/binding-linux-ppc64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-riscv64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-riscv64-musl": "0.62.0",
|
||||
"@oxfmt/binding-linux-s390x-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-x64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-x64-musl": "0.62.0",
|
||||
"@oxfmt/binding-openharmony-arm64": "0.62.0",
|
||||
"@oxfmt/binding-win32-arm64-msvc": "0.62.0",
|
||||
"@oxfmt/binding-win32-ia32-msvc": "0.62.0",
|
||||
"@oxfmt/binding-win32-x64-msvc": "0.62.0"
|
||||
"@oxfmt/binding-android-arm-eabi": "0.63.0",
|
||||
"@oxfmt/binding-android-arm64": "0.63.0",
|
||||
"@oxfmt/binding-darwin-arm64": "0.63.0",
|
||||
"@oxfmt/binding-darwin-x64": "0.63.0",
|
||||
"@oxfmt/binding-freebsd-x64": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm-musleabihf": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm64-musl": "0.63.0",
|
||||
"@oxfmt/binding-linux-ppc64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-riscv64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-riscv64-musl": "0.63.0",
|
||||
"@oxfmt/binding-linux-s390x-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-x64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-x64-musl": "0.63.0",
|
||||
"@oxfmt/binding-openharmony-arm64": "0.63.0",
|
||||
"@oxfmt/binding-win32-arm64-msvc": "0.63.0",
|
||||
"@oxfmt/binding-win32-ia32-msvc": "0.63.0",
|
||||
"@oxfmt/binding-win32-x64-msvc": "0.63.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
@@ -3211,16 +3341,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
|
||||
"integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
|
||||
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.66.0",
|
||||
"@typescript-eslint/parser": "8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
|
||||
"integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0",
|
||||
"@typescript-eslint/utils": "8.66.0"
|
||||
"@typescript-eslint/visitor-keys": "8.66.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3520,9 +3675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
|
||||
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -23,24 +23,24 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.2"
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^10.8.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -21,7 +21,7 @@ from functools import partial
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
from superset import db
|
||||
from superset import db, security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import DatabaseNotFoundError
|
||||
from superset.daos.database import DatabaseUserOAuth2TokensDAO
|
||||
@@ -31,6 +31,7 @@ from superset.exceptions import OAuth2Error
|
||||
from superset.key_value.types import JsonKeyValueCodec, KeyValueResource
|
||||
from superset.models.core import Database, DatabaseUserOAuth2Tokens
|
||||
from superset.superset_typing import OAuth2State
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.utils.oauth2 import decode_oauth2_state
|
||||
|
||||
@@ -121,6 +122,14 @@ class OAuth2StoreTokenCommand(BaseCommand):
|
||||
|
||||
self._state = decode_oauth2_state(self._parameters["state"])
|
||||
|
||||
# Bind the callback to the current session: require an authenticated,
|
||||
# non-guest user whose id matches the one carried in the state.
|
||||
user_id = get_user_id()
|
||||
if user_id is None or security_manager.is_guest_user():
|
||||
raise OAuth2Error("The OAuth2 callback requires an authenticated user")
|
||||
if user_id != self._state["user_id"]:
|
||||
raise OAuth2Error("The OAuth2 state belongs to a different user")
|
||||
|
||||
if database := DatabaseUserOAuth2TokensDAO.get_database(
|
||||
self._state["database_id"]
|
||||
):
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import gzip
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from http.client import HTTPConnection, HTTPResponse, HTTPSConnection
|
||||
from typing import Any
|
||||
from urllib import request
|
||||
from urllib.parse import urljoin, urlparse
|
||||
@@ -47,7 +50,7 @@ from superset.models.helpers import ChildMultipleResultsFound
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user
|
||||
from superset.utils.network import is_safe_host
|
||||
from superset.utils.network import is_safe_host, is_safe_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +79,47 @@ class _ValidatingRedirectHandler(HTTPRedirectHandler):
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _raise_for_unsafe_peer(sock: socket.socket) -> None:
|
||||
"""
|
||||
Validate that an established connection's actual peer is publicly
|
||||
routable, so the address reached matches the policy applied to the host.
|
||||
"""
|
||||
peer = sock.getpeername()[0]
|
||||
if not is_safe_ip(ipaddress.ip_address(peer)):
|
||||
raise DatasetForbiddenDataURI()
|
||||
|
||||
|
||||
class _PeerValidatingHTTPConnection(HTTPConnection):
|
||||
"""HTTP connection that validates the peer address on connect."""
|
||||
|
||||
def connect(self) -> None:
|
||||
super().connect()
|
||||
_raise_for_unsafe_peer(self.sock)
|
||||
|
||||
|
||||
class _PeerValidatingHTTPSConnection(HTTPSConnection):
|
||||
"""HTTPS connection that validates the peer address after the handshake."""
|
||||
|
||||
def connect(self) -> None:
|
||||
super().connect()
|
||||
_raise_for_unsafe_peer(self.sock)
|
||||
|
||||
|
||||
class _PeerValidatingHTTPHandler(request.HTTPHandler):
|
||||
"""Opens HTTP connections through the peer-validating connection class."""
|
||||
|
||||
def http_open(self, req: request.Request) -> HTTPResponse:
|
||||
return self.do_open(_PeerValidatingHTTPConnection, req)
|
||||
|
||||
|
||||
class _PeerValidatingHTTPSHandler(request.HTTPSHandler):
|
||||
"""Opens HTTPS connections through the peer-validating connection class."""
|
||||
|
||||
def https_open(self, req: request.Request) -> HTTPResponse:
|
||||
context = self._context # type: ignore[attr-defined]
|
||||
return self.do_open(_PeerValidatingHTTPSConnection, req, context=context)
|
||||
|
||||
|
||||
CHUNKSIZE = 512
|
||||
VARCHAR = re.compile(r"VARCHAR\((\d+)\)", re.IGNORECASE)
|
||||
|
||||
@@ -581,7 +625,17 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
|
||||
validate_data_uri(data_uri)
|
||||
logger.info("Downloading data from %s", data_uri)
|
||||
opener = request.build_opener(_ValidatingRedirectHandler)
|
||||
handlers: list[request.BaseHandler | type[request.BaseHandler]] = [
|
||||
_ValidatingRedirectHandler
|
||||
]
|
||||
if not app.config["DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS"]:
|
||||
# Also enforce the policy at the socket layer: re-check the peer of
|
||||
# every connection, including each redirect hop. Disable proxies so the
|
||||
# connection is made directly to the destination and the peer check
|
||||
# validates the destination address rather than a proxy's.
|
||||
handlers.append(request.ProxyHandler({}))
|
||||
handlers.extend([_PeerValidatingHTTPHandler, _PeerValidatingHTTPSHandler])
|
||||
opener = request.build_opener(*handlers)
|
||||
data = opener.open(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
||||
if data_uri.endswith(".gz"):
|
||||
data = gzip.open(data)
|
||||
|
||||
@@ -137,6 +137,29 @@ def resolve_executor_user(model: ReportSchedule) -> tuple["User", str]:
|
||||
return user, username
|
||||
|
||||
|
||||
def _should_build_execution_context(model: ReportSchedule) -> bool:
|
||||
"""
|
||||
Whether an execution should run under a :class:`ReportExecutionContext`.
|
||||
|
||||
Reports always do — their behavior is unchanged. Alerts join them only when
|
||||
they deliver a rendered PNG/PDF screenshot to recipients, which happens when
|
||||
``ALERTS_ATTACH_REPORTS`` is enabled. Delivered screenshots must fail closed:
|
||||
the context selects the fail-closed readiness predicate and disables
|
||||
partial-tile fallback, so a blank or incomplete capture raises instead of
|
||||
being delivered.
|
||||
|
||||
CSV/text alerts, alerts without the attach flag, the non-delivered
|
||||
query-context capture, and UI thumbnails are deliberately excluded and keep
|
||||
their lenient capture contract.
|
||||
"""
|
||||
if model.type == ReportScheduleType.REPORT:
|
||||
return True
|
||||
return model.report_format in (
|
||||
ReportDataFormat.PNG,
|
||||
ReportDataFormat.PDF,
|
||||
) and feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
|
||||
|
||||
|
||||
def log_report_delivery_phase(
|
||||
report_context: ReportExecutionContext | None,
|
||||
recipient_type: ReportRecipientType | None,
|
||||
@@ -1972,13 +1995,13 @@ class ReportSuccessState(BaseReportState):
|
||||
|
||||
try:
|
||||
self.send()
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
if self._handle_retry_or_error(str(ex), ex):
|
||||
except Exception as first_ex: # pylint: disable=broad-except
|
||||
if self._handle_retry_or_error(str(first_ex), first_ex):
|
||||
return # retry scheduled — exit cleanly
|
||||
|
||||
try:
|
||||
self.update_report_schedule_and_log(
|
||||
ReportState.ERROR, error_message=str(ex)
|
||||
ReportState.ERROR, error_message=str(first_ex)
|
||||
)
|
||||
except (ReportScheduleUnexpectedError, SQLAlchemyError) as logging_ex:
|
||||
# Logging failed (likely StaleDataError), but we still want to
|
||||
@@ -1991,7 +2014,45 @@ class ReportSuccessState(BaseReportState):
|
||||
exc_info=True,
|
||||
)
|
||||
# Re-raise the original exception, not the logging failure
|
||||
raise ex from logging_ex
|
||||
raise first_ex from logging_ex
|
||||
|
||||
# A delivery failure from the Success/Grace path must notify the
|
||||
# owner just like the first-run path (ReportNotTriggeredErrorState).
|
||||
# Without this, a schedule whose previous run succeeded would fail
|
||||
# silently — e.g. once a screenshot capture starts failing closed.
|
||||
# The error grace period still throttles repeated notifications.
|
||||
if not self.is_in_error_grace_period():
|
||||
second_error_message = REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
|
||||
try:
|
||||
self.send_error(
|
||||
f"Error occurred for {self._report_schedule.type}:"
|
||||
f" {self._report_schedule.name}",
|
||||
str(first_ex),
|
||||
)
|
||||
except SupersetErrorsException as second_ex:
|
||||
second_error_message = ";".join(
|
||||
[error.message for error in second_ex.errors]
|
||||
)
|
||||
except ReportScheduleUnexpectedError:
|
||||
# send_error failed due to logging issue; log and continue
|
||||
# to raise the original error
|
||||
logger.warning(
|
||||
"Failed to send error notification due to database issue",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as second_ex: # pylint: disable=broad-except
|
||||
second_error_message = str(second_ex)
|
||||
finally:
|
||||
try:
|
||||
self.update_report_schedule_and_log(
|
||||
ReportState.ERROR, error_message=second_error_message
|
||||
)
|
||||
except ReportScheduleUnexpectedError:
|
||||
# Logging failed again; log it but don't hide first_ex
|
||||
logger.warning(
|
||||
"Failed to log final error state due to database issue",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
# send() succeeded — clear retry state and log success. Any execution
|
||||
@@ -2058,13 +2119,18 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise ReportScheduleExecuteUnexpectedError()
|
||||
|
||||
if self._model.type == ReportScheduleType.REPORT:
|
||||
# Reports always run under an execution context; alerts join them
|
||||
# only when they deliver a rendered screenshot, so a blank/partial
|
||||
# capture fails closed instead of being delivered. Ownership and
|
||||
# terminal-error persistence remain report-only recovery semantics.
|
||||
if _should_build_execution_context(self._model):
|
||||
# An invocation that enters on WORKING is a duplicate or stale
|
||||
# recovery, not the owner that created the active row. Its state
|
||||
# handler may terminalize a stale execution, but the command
|
||||
# boundary must never infer ownership from a replayed UUID.
|
||||
owns_report_working_state = (
|
||||
self._model.last_state != ReportState.WORKING
|
||||
self._model.type == ReportScheduleType.REPORT
|
||||
and self._model.last_state != ReportState.WORKING
|
||||
)
|
||||
total_seconds = resolve_report_execution_budget_seconds(
|
||||
app.config,
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import db
|
||||
from superset.commands.streaming_export.base import BaseStreamingCSVExportCommand
|
||||
@@ -86,6 +87,15 @@ class StreamingSqlResultExportCommand(BaseStreamingCSVExportCommand):
|
||||
),
|
||||
status=403,
|
||||
) from ex
|
||||
except TemplateError as ex:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=str(ex),
|
||||
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
status=400,
|
||||
) from ex
|
||||
|
||||
def _get_sql_and_database(self) -> tuple[str, Any, str | None, str | None]:
|
||||
"""
|
||||
|
||||
@@ -58,6 +58,7 @@ from superset.utils.core import (
|
||||
get_column_name,
|
||||
get_column_names_from_columns,
|
||||
get_column_names_from_metrics,
|
||||
get_user_id,
|
||||
is_adhoc_column,
|
||||
is_adhoc_metric,
|
||||
)
|
||||
@@ -270,6 +271,11 @@ class QueryContextProcessor:
|
||||
datasource = self._qc_datasource
|
||||
extra_cache_keys = datasource.get_extra_cache_keys(query_obj.to_dict())
|
||||
|
||||
# Annotation data is cached on the same entry as the dataframe, so the
|
||||
# key must also bind the annotation sources' security context.
|
||||
if query_obj and query_obj.annotation_layers:
|
||||
kwargs["annotation_context"] = self._annotation_cache_context(query_obj)
|
||||
|
||||
cache_key = (
|
||||
query_obj.cache_key(
|
||||
datasource=datasource.uid,
|
||||
@@ -283,6 +289,32 @@ class QueryContextProcessor:
|
||||
)
|
||||
return cache_key
|
||||
|
||||
def _annotation_cache_context(self, query_obj: QueryObject) -> dict[str, Any]:
|
||||
"""
|
||||
Cache-key material binding cached annotation data to its security
|
||||
context.
|
||||
|
||||
Annotation payloads are fetched per requesting user and stored on the
|
||||
same cache entry as the dataframe, so the key also binds the requesting
|
||||
user and, for chart-backed layers, the RLS clauses of the referenced
|
||||
chart's datasource.
|
||||
"""
|
||||
source_rls: dict[str, list[str] | None] = {}
|
||||
for layer in query_obj.annotation_layers:
|
||||
if layer.get("sourceType") not in ("line", "table"):
|
||||
continue
|
||||
layer_value = layer.get("value")
|
||||
chart = (
|
||||
ChartDAO.find_by_id(layer_value) if layer_value is not None else None
|
||||
)
|
||||
annotation_datasource = chart.datasource if chart else None
|
||||
source_rls[str(layer.get("value"))] = (
|
||||
security_manager.get_rls_cache_key(annotation_datasource)
|
||||
if annotation_datasource
|
||||
else None
|
||||
)
|
||||
return {"user_id": get_user_id(), "source_rls": source_rls}
|
||||
|
||||
def get_query_result(self, query_object: QueryObject) -> QueryResult:
|
||||
"""
|
||||
Returns a pandas dataframe based on the query object.
|
||||
@@ -636,6 +668,11 @@ class QueryContextProcessor:
|
||||
if layer["sourceType"] == "NATIVE"
|
||||
]
|
||||
layer_ids = [layer["value"] for layer in annotation_layers]
|
||||
# Enforce the annotation read permission before returning layer records.
|
||||
if layer_ids and not security_manager.can_access("can_read", "Annotation"):
|
||||
raise QueryObjectValidationError(
|
||||
_("You don't have access to annotation layers")
|
||||
)
|
||||
layer_objects = {
|
||||
layer_object.id: layer_object
|
||||
for layer_object in AnnotationLayerDAO.find_by_ids(layer_ids)
|
||||
@@ -645,6 +682,15 @@ class QueryContextProcessor:
|
||||
for layer in annotation_layers:
|
||||
layer_id = layer["value"]
|
||||
layer_name = layer["name"]
|
||||
# A request may reference a layer id that does not exist; treat it
|
||||
# as a validation error rather than failing on the missing key.
|
||||
if (layer_object := layer_objects.get(layer_id)) is None:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Annotation layer with ID %(layer_id)s was not found",
|
||||
layer_id=layer_id,
|
||||
)
|
||||
)
|
||||
columns = [
|
||||
"start_dttm",
|
||||
"end_dttm",
|
||||
@@ -652,7 +698,6 @@ class QueryContextProcessor:
|
||||
"long_descr",
|
||||
"json_metadata",
|
||||
]
|
||||
layer_object = layer_objects[layer_id]
|
||||
records = [
|
||||
{column: getattr(annotation, column) for column in columns}
|
||||
for annotation in layer_object.annotation
|
||||
|
||||
@@ -34,6 +34,7 @@ from superset.commands.dashboard.exceptions import (
|
||||
DashboardUpdateFailedError,
|
||||
)
|
||||
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
|
||||
from superset.dashboards.filter_scope import derive_metadata_scopes
|
||||
from superset.dashboards.filters import DashboardAccessFilter
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
@@ -547,7 +548,9 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
cls, id: str
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
dashboard = cls.get_by_id_or_slug(id)
|
||||
metadata = json.loads(dashboard.json_metadata or "{}")
|
||||
metadata = derive_metadata_scopes(
|
||||
dashboard, json.loads(dashboard.json_metadata or "{}")
|
||||
)
|
||||
native_filter_configuration = metadata.get("native_filter_configuration", [])
|
||||
|
||||
tab_filters = defaultdict(list)
|
||||
@@ -617,6 +620,13 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
metadata["native_filter_configuration"] = updated_configuration
|
||||
dashboard.json_metadata = json.dumps(metadata)
|
||||
|
||||
# The client rebuilds its in-scope state from this response, so hand
|
||||
# back derived scopes rather than the stored caches, which are stale
|
||||
# for every filter the caller did not touch.
|
||||
updated_configuration = derive_metadata_scopes(dashboard, metadata)[
|
||||
"native_filter_configuration"
|
||||
]
|
||||
|
||||
return updated_configuration
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -91,6 +91,7 @@ from superset.commands.importers.v1.utils import get_contents_from_bundle
|
||||
from superset.commands.purge import PurgeArchivedCommand, SoftDeleteBinding
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
|
||||
from superset.dashboards.filter_scope import derive_json_metadata
|
||||
from superset.dashboards.filters import (
|
||||
DashboardAccessFilter,
|
||||
DashboardCertifiedFilter,
|
||||
@@ -653,6 +654,12 @@ class DashboardRestApi(
|
||||
schema = self.dashboard_get_response_schema
|
||||
|
||||
result = schema.dump(dash)
|
||||
if json_metadata := result.get("json_metadata"):
|
||||
# The stored scope caches (``chartsInScope``, ``tabsInScope``,
|
||||
# ``chart_configuration``) go stale as soon as the layout changes;
|
||||
# derive them so callers see the same document the dashboard client
|
||||
# computes for itself.
|
||||
result["json_metadata"] = derive_json_metadata(dash, json_metadata)
|
||||
if "charts" in result:
|
||||
# Only name the member charts the caller can access, consistent with
|
||||
# the per-object narrowing applied to the dashboard's datasets and
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Derive filter-scope caches in ``json_metadata`` from the dashboard layout.
|
||||
|
||||
``chartsInScope`` / ``tabsInScope`` on a native filter, and the ``chartsInScope``
|
||||
lists inside ``chart_configuration`` / ``global_chart_configuration``, are
|
||||
denormalized caches of the authoritative ``scope`` plus ``position_json``. They
|
||||
are written when a dashboard is saved and are never revisited afterwards, so a
|
||||
dashboard that has charts added or removed - or that was seeded, exported or
|
||||
imported - carries scope arrays naming charts it does not contain.
|
||||
|
||||
The dashboard client already ignores the stored values and recomputes them from
|
||||
the live layout on every load, which is why the JSON Metadata panel and
|
||||
``GET /api/v1/dashboard/{id}`` disagreed on a dashboard nobody had ever saved.
|
||||
Deriving them on read makes the API agree with the client and keeps integrations
|
||||
that read ``native_filter_configuration`` from receiving dangling chart ids.
|
||||
|
||||
The rules mirror the client (``superset-frontend/src/dashboard/util``):
|
||||
``calculateScopes``, ``getChartIdsInFilterScope``, ``findTabsWithChartsInScope``
|
||||
and ``getCrossFiltersConfiguration``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from superset.utils import json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
CHART_TYPE = "CHART"
|
||||
TAB_TYPE = "TAB"
|
||||
NATIVE_FILTER_DIVIDER_PREFIX = "NATIVE_FILTER_DIVIDER-"
|
||||
DIVIDER_TYPES = frozenset({"DIVIDER", "CHART_CUSTOMIZATION_DIVIDER"})
|
||||
|
||||
# ``chart-<chartId>-layer-<layerIndex>``, the per-layer scope keys a deck.gl
|
||||
# multi-layer chart contributes to ``scope.selectedLayers``.
|
||||
LAYER_SELECTION_RE = re.compile(r"^chart-(\d+)-layer-(\d+)$")
|
||||
|
||||
ChartLayoutItems = dict[int, list[dict[str, Any]]]
|
||||
|
||||
|
||||
def build_chart_layout_items(position_data: dict[str, Any]) -> ChartLayoutItems:
|
||||
"""Map each chart id in the layout to the layout items that render it."""
|
||||
chart_layout_items: ChartLayoutItems = {}
|
||||
for item in position_data.values():
|
||||
if not isinstance(item, dict) or item.get("type") != CHART_TYPE:
|
||||
continue
|
||||
chart_id = item.get("meta", {}).get("chartId")
|
||||
if isinstance(chart_id, int):
|
||||
chart_layout_items.setdefault(chart_id, []).append(item)
|
||||
return chart_layout_items
|
||||
|
||||
|
||||
def get_chart_ids_in_scope(
|
||||
scope: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[int]:
|
||||
"""Charts covered by ``scope``, in ``chart_ids`` order."""
|
||||
excluded = set(scope.get("excluded") or [])
|
||||
root_path = set(scope.get("rootPath") or [])
|
||||
|
||||
def in_scope(chart_id: int) -> bool:
|
||||
if chart_id in excluded:
|
||||
return False
|
||||
return any(
|
||||
parent in root_path
|
||||
for layout_item in chart_layout_items.get(chart_id, [])
|
||||
for parent in layout_item.get("parents") or []
|
||||
)
|
||||
|
||||
selected_layers = scope.get("selectedLayers") or []
|
||||
if not selected_layers:
|
||||
return [chart_id for chart_id in chart_ids if in_scope(chart_id)]
|
||||
|
||||
# A layer selection targets its chart directly, and suppresses the
|
||||
# rootPath/excluded test for that chart.
|
||||
charts_with_layer_selections = set()
|
||||
targeted: list[int] = []
|
||||
chart_id_set = set(chart_ids)
|
||||
for selection_key in selected_layers:
|
||||
if match := LAYER_SELECTION_RE.match(str(selection_key)):
|
||||
chart_id = int(match.group(1))
|
||||
charts_with_layer_selections.add(chart_id)
|
||||
if chart_id in chart_id_set and chart_id not in targeted:
|
||||
targeted.append(chart_id)
|
||||
|
||||
return targeted + [
|
||||
chart_id
|
||||
for chart_id in chart_ids
|
||||
if chart_id not in charts_with_layer_selections
|
||||
and chart_id not in targeted
|
||||
and in_scope(chart_id)
|
||||
]
|
||||
|
||||
|
||||
def get_tabs_in_scope(
|
||||
charts_in_scope: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[str]:
|
||||
"""Tabs holding at least one of ``charts_in_scope``."""
|
||||
tabs_in_scope: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for chart_id in charts_in_scope:
|
||||
for layout_item in chart_layout_items.get(chart_id, []):
|
||||
for parent in layout_item.get("parents") or []:
|
||||
if parent.startswith(f"{TAB_TYPE}-") and parent not in seen:
|
||||
seen.add(parent)
|
||||
tabs_in_scope.append(parent)
|
||||
return tabs_in_scope
|
||||
|
||||
|
||||
def _is_divider(item: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(item.get("id", "")).startswith(NATIVE_FILTER_DIVIDER_PREFIX)
|
||||
or item.get("type") in DIVIDER_TYPES
|
||||
)
|
||||
|
||||
|
||||
def _derive_item_scopes(
|
||||
items: list[Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[Any]:
|
||||
"""Restamp ``chartsInScope`` / ``tabsInScope`` on scoped config items.
|
||||
|
||||
Items without a usable ``scope`` are returned untouched: legacy chart
|
||||
customizations target a chart directly and only gain a ``scope`` once the
|
||||
client migrates them, so overwriting their cache here would drop targeting
|
||||
the client still needs.
|
||||
"""
|
||||
derived = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
derived.append(item)
|
||||
continue
|
||||
if _is_divider(item):
|
||||
derived.append({**item, "chartsInScope": [], "tabsInScope": []})
|
||||
continue
|
||||
scope = item.get("scope")
|
||||
if not isinstance(scope, dict) or not isinstance(scope.get("excluded"), list):
|
||||
derived.append(item)
|
||||
continue
|
||||
charts_in_scope = get_chart_ids_in_scope(scope, chart_ids, chart_layout_items)
|
||||
derived.append(
|
||||
{
|
||||
**item,
|
||||
"chartsInScope": charts_in_scope,
|
||||
"tabsInScope": get_tabs_in_scope(charts_in_scope, chart_layout_items),
|
||||
}
|
||||
)
|
||||
return derived
|
||||
|
||||
|
||||
def _derive_cross_filter_scopes(
|
||||
metadata: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> None:
|
||||
global_config = metadata.get("global_chart_configuration")
|
||||
global_charts_in_scope = chart_ids
|
||||
if isinstance(global_config, dict) and isinstance(global_config.get("scope"), dict):
|
||||
global_charts_in_scope = get_chart_ids_in_scope(
|
||||
global_config["scope"], chart_ids, chart_layout_items
|
||||
)
|
||||
metadata["global_chart_configuration"] = {
|
||||
**global_config,
|
||||
"chartsInScope": global_charts_in_scope,
|
||||
}
|
||||
|
||||
chart_configuration = metadata.get("chart_configuration")
|
||||
if not isinstance(chart_configuration, dict):
|
||||
return
|
||||
|
||||
derived_configuration = {}
|
||||
for key, config in chart_configuration.items():
|
||||
try:
|
||||
chart_id = int(key)
|
||||
except (TypeError, ValueError):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
# Config for a chart no longer on the dashboard is dead weight; the
|
||||
# client drops it on load for the same reason.
|
||||
if chart_id not in chart_layout_items:
|
||||
continue
|
||||
if not isinstance(config, dict):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
cross_filters = config.get("crossFilters")
|
||||
if not isinstance(cross_filters, dict):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
scope = cross_filters.get("scope")
|
||||
if isinstance(scope, dict):
|
||||
charts_in_scope = get_chart_ids_in_scope(
|
||||
scope, chart_ids, chart_layout_items
|
||||
)
|
||||
else:
|
||||
# Anything that is not an explicit scope object points at the
|
||||
# dashboard-wide scope, which never includes the emitting chart.
|
||||
charts_in_scope = [cid for cid in global_charts_in_scope if cid != chart_id]
|
||||
derived_configuration[key] = {
|
||||
**config,
|
||||
"crossFilters": {**cross_filters, "chartsInScope": charts_in_scope},
|
||||
}
|
||||
|
||||
metadata["chart_configuration"] = derived_configuration
|
||||
|
||||
|
||||
def derive_scopes(
|
||||
metadata: dict[str, Any],
|
||||
position_data: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
) -> dict[str, Any]:
|
||||
"""Return ``metadata`` with every derived scope cache recomputed.
|
||||
|
||||
``chart_ids`` orders the resulting ``chartsInScope`` arrays and should be the
|
||||
dashboard's chart ids as the client sees them, so that the API and the JSON
|
||||
Metadata panel produce byte-identical documents.
|
||||
"""
|
||||
derived = dict(metadata)
|
||||
chart_layout_items = build_chart_layout_items(position_data)
|
||||
|
||||
for key in ("native_filter_configuration", "chart_customization_config"):
|
||||
config = derived.get(key)
|
||||
if isinstance(config, list):
|
||||
derived[key] = _derive_item_scopes(config, chart_ids, chart_layout_items)
|
||||
|
||||
_derive_cross_filter_scopes(derived, chart_ids, chart_layout_items)
|
||||
return derived
|
||||
|
||||
|
||||
def derive_metadata_scopes(
|
||||
dashboard: Dashboard,
|
||||
metadata: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""``derive_scopes`` for a dashboard model's parsed ``json_metadata``."""
|
||||
return derive_scopes(
|
||||
metadata,
|
||||
dashboard.position,
|
||||
[slc.id for slc in dashboard.slices],
|
||||
)
|
||||
|
||||
|
||||
def derive_json_metadata(dashboard: Dashboard, json_metadata: str) -> str:
|
||||
"""``derive_metadata_scopes`` over a raw ``json_metadata`` string.
|
||||
|
||||
Metadata that does not parse as a JSON object is handed back untouched -
|
||||
reading a dashboard is not the place to start rejecting documents that have
|
||||
always been served as-is.
|
||||
"""
|
||||
try:
|
||||
metadata = json.loads(json_metadata)
|
||||
except (TypeError, ValueError):
|
||||
return json_metadata
|
||||
if not isinstance(metadata, dict):
|
||||
return json_metadata
|
||||
return json.dumps(derive_metadata_scopes(dashboard, metadata))
|
||||
@@ -165,12 +165,31 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.debug("Unable to load SQLAlchemy dialect %s: %s", ep.name, ex)
|
||||
else:
|
||||
backend = dialect.name
|
||||
# A third-party entry point can load successfully yet not resolve to
|
||||
# a usable dialect. Validate the same dialect contract as the native
|
||||
# loop so malformed connectors are neither advertised nor allowed to
|
||||
# abort the whole enumeration.
|
||||
backend = getattr(dialect, "name", None)
|
||||
if (
|
||||
not isinstance(dialect, type)
|
||||
or not issubclass(dialect, DefaultDialect)
|
||||
or not isinstance(backend, (str, bytes))
|
||||
or not hasattr(dialect, "driver")
|
||||
or dialect.driver == "adodbapi"
|
||||
):
|
||||
logger.warning(
|
||||
"Skipping SQLAlchemy dialect entry point %r: %r did not "
|
||||
"resolve to a usable dialect (%r)",
|
||||
ep.name,
|
||||
ep.value,
|
||||
dialect,
|
||||
)
|
||||
continue
|
||||
if isinstance(backend, bytes):
|
||||
backend = backend.decode()
|
||||
backend = backend_replacements.get(backend, backend)
|
||||
|
||||
driver = getattr(dialect, "driver", dialect.name)
|
||||
driver = dialect.driver
|
||||
if isinstance(driver, bytes):
|
||||
driver = driver.decode()
|
||||
drivers[backend].add(driver)
|
||||
|
||||
@@ -26,8 +26,12 @@ if TYPE_CHECKING:
|
||||
# Matches only the static asset endpoint:
|
||||
# /api/v1/extensions/<publisher>/<name>/<path:file>, where the file portion may
|
||||
# contain nested segments (worker / WASM / chunk subfolders).
|
||||
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info) endpoints.
|
||||
_ASSET_PATH_RE: re.Pattern[str] = re.compile(r"^/api/v1/extensions/[^/]+/[^/]+/.+$")
|
||||
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info)
|
||||
# endpoints, nor the per-user storage endpoints under
|
||||
# /<publisher>/<name>/storage/, whose responses must keep ``Vary: Cookie``.
|
||||
_ASSET_PATH_RE: re.Pattern[str] = re.compile(
|
||||
r"^/api/v1/extensions/[^/]+/[^/]+/(?!storage/).+$"
|
||||
)
|
||||
|
||||
|
||||
class ExtensionCacheMiddleware:
|
||||
|
||||
@@ -92,10 +92,16 @@ class ExtensionStorageRestApi(BaseApi):
|
||||
route_base = "/api/v1/extensions"
|
||||
|
||||
def response(self, status_code: int, **kwargs: Any) -> Response:
|
||||
"""Helper method to create JSON responses."""
|
||||
"""Helper method to create JSON responses.
|
||||
|
||||
Stored values are scoped to the requesting user, so responses are
|
||||
marked non-cacheable.
|
||||
"""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify(kwargs), status_code
|
||||
response = jsonify(kwargs)
|
||||
response.cache_control.no_store = True
|
||||
return response, status_code
|
||||
|
||||
def response_404(self, message: str = "Not found") -> Response:
|
||||
"""Helper method to create 404 responses."""
|
||||
|
||||
@@ -1272,6 +1272,34 @@ def get_dataset_id_from_context(metric_key: str) -> int:
|
||||
raise SupersetTemplateException(exc_message)
|
||||
|
||||
|
||||
def guest_user_can_access_dataset(dataset: SqlaTable) -> bool:
|
||||
"""
|
||||
Whether the current guest (embedded) user may read the given dataset.
|
||||
|
||||
Guest access is granted per dashboard, so the dataset must back at least
|
||||
one chart on a dashboard the guest token covers; a ``datasets`` allowlist
|
||||
on the token further restricts the reachable IDs.
|
||||
|
||||
:param dataset: a dataset resolved without the DAO base filter.
|
||||
:returns: whether the guest user may read the dataset.
|
||||
"""
|
||||
guest_user = security_manager.get_current_guest_user_if_guest()
|
||||
if not guest_user:
|
||||
return False
|
||||
|
||||
allowed_datasets: list[int] | None = guest_user.guest_token.get("datasets")
|
||||
if allowed_datasets is not None and (
|
||||
not isinstance(allowed_datasets, list) or dataset.id not in allowed_datasets
|
||||
):
|
||||
return False
|
||||
|
||||
return any(
|
||||
security_manager.has_guest_access(dashboard)
|
||||
for slc in dataset.slices
|
||||
for dashboard in slc.dashboards
|
||||
)
|
||||
|
||||
|
||||
def metric_macro(
|
||||
env: Environment,
|
||||
context: dict[str, Any],
|
||||
@@ -1294,8 +1322,9 @@ def metric_macro(
|
||||
if not dataset_id:
|
||||
dataset_id = get_dataset_id_from_context(metric_key)
|
||||
|
||||
# Embedded user access is validated at the dashboard level, so we bypass
|
||||
# the regular DAO filter for them
|
||||
# Embedded (guest) user access is validated at the dashboard level, so the
|
||||
# regular DAO filter is bypassed for them and dashboard-level scope is
|
||||
# enforced explicitly below.
|
||||
dataset = DatasetDAO.find_by_id(
|
||||
dataset_id,
|
||||
skip_base_filter=security_manager.is_guest_user(),
|
||||
@@ -1303,6 +1332,11 @@ def metric_macro(
|
||||
if not dataset:
|
||||
raise DatasetNotFoundError(f"Dataset ID {dataset_id} not found.")
|
||||
|
||||
# With the base filter skipped, scope a guest to datasets reachable through
|
||||
# a dashboard their token grants; reuse the not-found error for consistency.
|
||||
if security_manager.is_guest_user() and not guest_user_can_access_dataset(dataset):
|
||||
raise DatasetNotFoundError(f"Dataset ID {dataset_id} not found.")
|
||||
|
||||
metrics: dict[str, str] = {
|
||||
metric.metric_name: metric.expression for metric in dataset.metrics
|
||||
}
|
||||
|
||||
@@ -754,15 +754,24 @@ def _native_filter_query_modified(
|
||||
query: Any, allowed_columns: set[str], allowed_metrics: set[str]
|
||||
) -> bool:
|
||||
"""Whether a single query in a native-filter request reads beyond its targets."""
|
||||
# Columns and group-by may only reference target column(s); adhoc (free-form
|
||||
# SQL) columns cannot be validated, so reject them.
|
||||
for key in ("columns", "groupby"):
|
||||
# Columns, group-by, and series columns may only reference target column(s);
|
||||
# adhoc (free-form SQL) columns cannot be validated, so reject them.
|
||||
for key in ("columns", "groupby", "series_columns"):
|
||||
for col in getattr(query, key, None) or []:
|
||||
if not isinstance(col, str) or col not in allowed_columns:
|
||||
return True
|
||||
for metric in getattr(query, "metrics", None) or []:
|
||||
if not _native_filter_term_allowed(metric, allowed_columns, allowed_metrics):
|
||||
return True
|
||||
# A series-limit metric ranks the top-N groups in the inner query, so it is
|
||||
# a value-returning term and is validated like a metric. ``QueryObject``
|
||||
# renames the deprecated ``timeseries_limit_metric`` payload key onto this
|
||||
# attribute, so both spellings are covered.
|
||||
series_limit_metric = getattr(query, "series_limit_metric", None)
|
||||
if series_limit_metric and not _native_filter_term_allowed(
|
||||
series_limit_metric, allowed_columns, allowed_metrics
|
||||
):
|
||||
return True
|
||||
# order-by entries are ``(expression, asc)`` pairs.
|
||||
for order in getattr(query, "orderby", None) or []:
|
||||
expr = order[0] if isinstance(order, (list, tuple)) and order else order
|
||||
@@ -784,8 +793,9 @@ def _native_filter_request_modified(query_context: "QueryContext") -> bool:
|
||||
A native filter may only read the column(s) it targets on the dashboard it
|
||||
belongs to. The request is treated as modified (and therefore rejected for
|
||||
guest users) when it cannot be tied to a native filter on the requesting
|
||||
dashboard, or when any value-returning term (column, group-by, metric, or
|
||||
order-by) references something other than a target column, a simple
|
||||
dashboard, or when any value-returning term (column, group-by, series
|
||||
column, metric, series-limit metric, or order-by) references something
|
||||
other than a target column, a simple
|
||||
aggregate over a target column, or the filter's configured sort metric.
|
||||
Free-form SQL terms and saved metrics other than the configured sort metric
|
||||
are rejected. Row-restricting clauses (``filter``/``extras``) are not
|
||||
@@ -4297,6 +4307,15 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
child_slice_id=slice_id,
|
||||
parent_slice=parent_slc,
|
||||
)
|
||||
# Bind the request to the child
|
||||
# chart's own datasource, mirroring
|
||||
# the direct-chart leg above.
|
||||
and (
|
||||
child_slc := self.session.query(Slice)
|
||||
.filter(Slice.id == slice_id)
|
||||
.one_or_none()
|
||||
)
|
||||
and child_slc.datasource == datasource
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -324,7 +324,9 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
MetricMetadata(
|
||||
metric_name=metric.name,
|
||||
expression=metric.definition,
|
||||
verbose_name=metric.verbose_name,
|
||||
description=metric.description,
|
||||
d3format=metric.d3format,
|
||||
)
|
||||
for metric in self.implementation.get_metrics()
|
||||
]
|
||||
@@ -357,6 +359,7 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
is_dttm=pa.types.is_date(dimension.type)
|
||||
or pa.types.is_time(dimension.type)
|
||||
or pa.types.is_timestamp(dimension.type),
|
||||
verbose_name=dimension.verbose_name,
|
||||
description=dimension.description,
|
||||
expression=None,
|
||||
extra=json.dumps(
|
||||
@@ -372,6 +375,19 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
|
||||
@property
|
||||
def data(self) -> ExplorableData:
|
||||
dimensions = self._unique_dimensions
|
||||
metrics = list(self.implementation.get_metrics())
|
||||
verbose_map = {
|
||||
**{metric.name: metric.verbose_name or metric.name for metric in metrics},
|
||||
**{
|
||||
dimension.name: dimension.verbose_name or dimension.name
|
||||
for dimension in dimensions
|
||||
},
|
||||
}
|
||||
column_formats = {
|
||||
metric.name: metric.d3format for metric in metrics if metric.d3format
|
||||
}
|
||||
|
||||
return {
|
||||
# core
|
||||
"id": self.id,
|
||||
@@ -399,16 +415,16 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"python_date_format": None,
|
||||
"type": str(dimension.type),
|
||||
"type_generic": get_column_type(dimension.type),
|
||||
"verbose_name": None,
|
||||
"verbose_name": dimension.verbose_name,
|
||||
"warning_markdown": None,
|
||||
}
|
||||
for dimension in self._unique_dimensions
|
||||
for dimension in dimensions
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"certification_details": None,
|
||||
"certified_by": None,
|
||||
"d3format": None,
|
||||
"d3format": metric.d3format,
|
||||
"description": metric.description,
|
||||
"expression": metric.definition,
|
||||
"id": None,
|
||||
@@ -417,14 +433,14 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"metric_name": metric.name,
|
||||
"warning_markdown": None,
|
||||
"warning_text": None,
|
||||
"verbose_name": None,
|
||||
"verbose_name": metric.verbose_name,
|
||||
}
|
||||
for metric in self.implementation.get_metrics()
|
||||
for metric in metrics
|
||||
],
|
||||
"database": {},
|
||||
"parent": {"name": self.semantic_layer.name},
|
||||
# UI features
|
||||
"verbose_map": {},
|
||||
"verbose_map": verbose_map,
|
||||
"order_by_choices": [],
|
||||
"filter_select": True,
|
||||
"filter_select_enabled": True,
|
||||
@@ -436,11 +452,11 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"description": self.description,
|
||||
"table_name": self.name,
|
||||
"column_types": [
|
||||
get_column_type(dimension.type) for dimension in self._unique_dimensions
|
||||
get_column_type(dimension.type) for dimension in dimensions
|
||||
],
|
||||
"column_names": [dimension.name for dimension in self._unique_dimensions],
|
||||
"column_names": [dimension.name for dimension in dimensions],
|
||||
# rare
|
||||
"column_formats": {},
|
||||
"column_formats": column_formats,
|
||||
"datasource_name": self.name,
|
||||
"perm": self.perm,
|
||||
"offset": self.offset,
|
||||
|
||||
@@ -129,7 +129,12 @@ class TaskContext(CoreTaskContext):
|
||||
"""
|
||||
from superset.daos.tasks import TaskDAO
|
||||
|
||||
fresh_task = TaskDAO.find_one_or_none(uuid=self._task_uuid)
|
||||
# Internal executor path: load the running task itself, keyed on a
|
||||
# UUID this instance already holds, not a user-requested lookup;
|
||||
# see TaskFilter for the request-scoped vs. internal-plumbing split.
|
||||
fresh_task = TaskDAO.find_one_or_none(
|
||||
uuid=self._task_uuid, skip_base_filter=True
|
||||
)
|
||||
if not fresh_task:
|
||||
raise ValueError(f"Task {self._task_uuid} not found")
|
||||
|
||||
|
||||
@@ -167,6 +167,12 @@ class TaskWrapper(Generic[P]):
|
||||
return value is discarded.
|
||||
|
||||
Direct calls execute synchronously, .schedule() runs async via Celery.
|
||||
|
||||
The status-refresh reads below pass ``skip_base_filter=True`` to
|
||||
``TaskDAO.find_one_or_none`` because they read back the task this
|
||||
executor itself submitted, keyed on the UUID it already holds -- not
|
||||
a task requested by a user. See ``TaskFilter`` for the request-scoped
|
||||
vs. internal-plumbing split.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -378,7 +384,7 @@ class TaskWrapper(Generic[P]):
|
||||
task.uuid,
|
||||
)
|
||||
# Return task in current state (caller can check status)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid, skip_base_filter=True)
|
||||
return refreshed if refreshed else task
|
||||
|
||||
def _execute_inline(
|
||||
@@ -422,7 +428,7 @@ class TaskWrapper(Generic[P]):
|
||||
set_ended_at=True,
|
||||
).run()
|
||||
# Refresh to get updated task
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid, skip_base_filter=True)
|
||||
return refreshed if refreshed else task
|
||||
|
||||
# Atomic transition: PENDING → IN_PROGRESS (set started_at for duration
|
||||
@@ -441,7 +447,7 @@ class TaskWrapper(Generic[P]):
|
||||
self.name,
|
||||
task_uuid,
|
||||
)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return refreshed if refreshed else task
|
||||
|
||||
# Update cached status (no DB read needed - we just wrote IN_PROGRESS)
|
||||
@@ -520,7 +526,7 @@ class TaskWrapper(Generic[P]):
|
||||
)
|
||||
|
||||
# Refresh once at end to return current state
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return final_task if final_task else task
|
||||
|
||||
except Exception as ex:
|
||||
@@ -542,7 +548,7 @@ class TaskWrapper(Generic[P]):
|
||||
)
|
||||
|
||||
# Refresh once at end to return current state
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return final_task if final_task else task
|
||||
|
||||
finally:
|
||||
@@ -552,7 +558,9 @@ class TaskWrapper(Generic[P]):
|
||||
# Publish completion notification for any waiters
|
||||
# Use final_task if set by try/except, otherwise refresh (fallback)
|
||||
if final_task is None:
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
final_task = TaskDAO.find_one_or_none(
|
||||
uuid=task_uuid, skip_base_filter=True
|
||||
)
|
||||
if final_task and final_task.status in TERMINAL_STATES:
|
||||
TaskManager.publish_completion(task_uuid, final_task.status)
|
||||
|
||||
|
||||
@@ -33,20 +33,35 @@ class TaskFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
owned and shared tasks. Unsubscribing removes visibility.
|
||||
|
||||
Admins see all tasks without filtering.
|
||||
|
||||
This filter applies to request-scoped reads only -- the REST API and
|
||||
the MCP task tools -- where a task's visibility to the requesting
|
||||
principal matters. Internal task-executor and scheduler code that
|
||||
reads back the state of a task it already owns (e.g. polling for the
|
||||
terminal status of the task it is currently executing) calls the DAO
|
||||
with ``skip_base_filter=True`` instead: that code isn't presenting
|
||||
task data to a user, and the UUID it operates on is never
|
||||
caller-supplied, so the visibility check doesn't apply.
|
||||
"""
|
||||
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
"""Apply the filter to the query."""
|
||||
from sqlalchemy import and_, select
|
||||
from flask import has_request_context
|
||||
from sqlalchemy import and_, false, select
|
||||
|
||||
from superset import security_manager
|
||||
from superset.models.task_subscribers import TaskSubscriber
|
||||
from superset.models.tasks import Task
|
||||
|
||||
# If user is admin or no user_id, return unfiltered query.
|
||||
# This typically applies to background tasks and system operations
|
||||
user_id = get_user_id()
|
||||
if not user_id or security_manager.is_admin():
|
||||
if not user_id:
|
||||
# Within a request, a principal without a user id gets no tasks;
|
||||
# background jobs run outside a request context and are unfiltered.
|
||||
if has_request_context():
|
||||
return query.filter(false())
|
||||
return query
|
||||
|
||||
if security_manager.is_admin():
|
||||
return query
|
||||
|
||||
is_subscribed = (
|
||||
|
||||
@@ -259,10 +259,15 @@ class TaskManager:
|
||||
return remaining if remaining > 0 else 0
|
||||
|
||||
def get_task() -> "Task | None":
|
||||
# Reads back the task named by the caller's own task_uuid, not
|
||||
# a user-requested lookup; see TaskFilter for the
|
||||
# request-scoped vs. internal-plumbing split.
|
||||
if app and not has_app_context():
|
||||
with app.app_context():
|
||||
return TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
return TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
return TaskDAO.find_one_or_none(
|
||||
uuid=task_uuid, skip_base_filter=True
|
||||
)
|
||||
return TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
|
||||
# Check current state first
|
||||
task = get_task()
|
||||
@@ -478,7 +483,9 @@ class TaskManager:
|
||||
"""
|
||||
from superset.daos.tasks import TaskDAO
|
||||
|
||||
task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
# Internal control-flow check on the task the executor is already
|
||||
# running, not a user-facing lookup; see TaskFilter.
|
||||
task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return task is not None and task.status in ABORT_STATES
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -311,7 +311,11 @@ def execute_task( # noqa: C901
|
||||
# Convert string UUID to native UUID (Celery deserializes as string)
|
||||
native_uuid = UUID(task_uuid)
|
||||
|
||||
task = TaskDAO.find_one_or_none(uuid=native_uuid)
|
||||
# Internal executor path: load the task Celery was dispatched to run,
|
||||
# keyed on the UUID passed at enqueue time, not a user-requested
|
||||
# lookup; see TaskFilter for the request-scoped vs. internal-plumbing
|
||||
# split. The refreshes below load the same task for the same reason.
|
||||
task = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
|
||||
if not task:
|
||||
logger.error("Task %s not found in metastore", task_uuid)
|
||||
return {"status": "error", "message": "Task not found"}
|
||||
@@ -346,7 +350,7 @@ def execute_task( # noqa: C901
|
||||
task_type,
|
||||
task_uuid,
|
||||
)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
|
||||
return {
|
||||
"status": refreshed.status if refreshed else "unknown",
|
||||
"task_uuid": task_uuid,
|
||||
@@ -489,7 +493,7 @@ def execute_task( # noqa: C901
|
||||
)
|
||||
|
||||
# Refresh to get final status for return value and completion notification
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
|
||||
final_status = refreshed.status if refreshed else "unknown"
|
||||
|
||||
# Publish completion notification for any waiters (e.g., sync callers)
|
||||
|
||||
@@ -140,11 +140,17 @@ def is_safe_redirect_url(url: str) -> bool:
|
||||
# following a Location header).
|
||||
stripped = _URL_STRIPPED_CONTROL_CHARS.sub("", url.strip())
|
||||
|
||||
# Block protocol-relative URLs
|
||||
if stripped.startswith("//") or stripped.startswith("\\\\"):
|
||||
# WHATWG URL parsers treat backslashes as forward slashes in special
|
||||
# schemes, while urllib does not. Normalize backslashes to slashes before
|
||||
# every structural check, mirroring Django's
|
||||
# ``url_has_allowed_host_and_scheme``.
|
||||
normalized = stripped.replace("\\", "/")
|
||||
|
||||
# Block protocol-relative URLs (any leading mix of slash and backslash)
|
||||
if normalized.startswith("//"):
|
||||
return False
|
||||
|
||||
parsed = urlparse(stripped)
|
||||
parsed = urlparse(normalized)
|
||||
|
||||
# Relative paths are safe
|
||||
if not parsed.scheme and not parsed.netloc:
|
||||
|
||||
@@ -44,6 +44,19 @@ PORT_TIMEOUT = 5
|
||||
PING_TIMEOUT = 5
|
||||
|
||||
|
||||
def is_safe_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""
|
||||
Return True if a single IP address is public and globally routable.
|
||||
|
||||
IPv4-mapped IPv6 addresses (e.g. ``::ffff:127.0.0.1``) are unwrapped so
|
||||
they are checked against the IPv4 unsafe networks rather than bypassing
|
||||
them.
|
||||
"""
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
return ip.is_global and not any(ip in net for net in _SSRF_UNSAFE_NETWORKS)
|
||||
|
||||
|
||||
def is_safe_host(host: str) -> bool:
|
||||
"""
|
||||
Return True if ``host`` resolves exclusively to public, globally-routable
|
||||
@@ -52,6 +65,10 @@ def is_safe_host(host: str) -> bool:
|
||||
Returns False if any resolved address falls within a private, loopback,
|
||||
link-local, or otherwise non-routable range. An unresolvable host also
|
||||
returns False.
|
||||
|
||||
Name resolution here is independent of the resolution performed when a
|
||||
connection is later opened, so callers that go on to fetch from ``host``
|
||||
should also validate the connected peer address (see ``is_safe_ip``).
|
||||
"""
|
||||
try:
|
||||
results = socket.getaddrinfo(host, None)
|
||||
@@ -64,11 +81,7 @@ def is_safe_host(host: str) -> bool:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
except ValueError:
|
||||
return False
|
||||
# Unwrap IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) so they
|
||||
# are checked against the IPv4 unsafe networks rather than bypassing.
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
if not ip.is_global or any(ip in net for net in _SSRF_UNSAFE_NETWORKS):
|
||||
if not is_safe_ip(ip):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -144,6 +144,21 @@ TERMINAL_MARKER_SELECTOR = (
|
||||
)
|
||||
CHART_ID_CLASS_PATTERN = r"\bdashboard-chart-id-(\d+)\b"
|
||||
|
||||
# ECharts paint marker. The frontend
|
||||
# (plugins/plugin-chart-echarts/src/components/Echart.tsx) tags the canvas host
|
||||
# ``.echarts-host`` and adds ``.echarts-render-finished`` only in the ECharts
|
||||
# ``finished`` event -- the sole signal that the canvas is fully painted.
|
||||
# ``.slice_container`` alone is a pre-paint signal (it mounts when data arrives,
|
||||
# before the canvas is drawn; chartStatus/onRenderSuccess fire pre-paint too), so
|
||||
# a holder that still contains an unpainted host is treated as not-yet-rendered and
|
||||
# the report screenshot waits for it instead of capturing a blank chart. Only
|
||||
# ECharts hosts are gated; DOM/SVG vizzes paint on commit and non-ECharts canvas
|
||||
# vizzes (deck.gl/mapbox/etc.) have no ``.echarts-host`` so they are unaffected.
|
||||
ECHARTS_UNPAINTED_HOST_SELECTOR = r".echarts-host:not(.echarts-render-finished)"
|
||||
CHART_ERROR_OR_EMPTY_SELECTOR = (
|
||||
f"{ALERT_SELECTOR}, {EMPTY_SELECTOR}, {MISSING_CHART_SELECTOR}"
|
||||
)
|
||||
|
||||
# Shared body for holder readiness and timeout diagnostics. A holder is ready
|
||||
# only after a terminal marker appears and its loading marker disappears.
|
||||
UNREADY_CHART_HOLDERS_JS_BODY = f"""
|
||||
@@ -158,8 +173,19 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
|
||||
'{SLICE_CONTAINER_SELECTOR}'
|
||||
) !== null;
|
||||
const stillLoading = holder.querySelector('{LOADING_SELECTOR}') !== null;
|
||||
const isReady = holder.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
|
||||
if (stillLoading || !isReady) {{
|
||||
const hasErrorOrEmpty = holder.querySelector(
|
||||
'{CHART_ERROR_OR_EMPTY_SELECTOR}'
|
||||
) !== null;
|
||||
const hasUnpaintedEchart = holder.querySelector(
|
||||
'{ECHARTS_UNPAINTED_HOST_SELECTOR}'
|
||||
) !== null;
|
||||
// Ready = a settled error/empty/missing state, or a slice container
|
||||
// whose ECharts canvas has finished painting. An unpainted ECharts host
|
||||
// keeps the holder unready so a blank chart is never captured.
|
||||
const isReady = !stillLoading && (
|
||||
hasErrorOrEmpty || (hasSliceContainer && !hasUnpaintedEchart)
|
||||
);
|
||||
if (!isReady) {{
|
||||
const chartIdMatch = holder.className.match(/{CHART_ID_CLASS_PATTERN}/);
|
||||
const chartId = chartIdMatch ? chartIdMatch[1] : null;
|
||||
let state;
|
||||
@@ -167,6 +193,8 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
|
||||
state = 'spinner_mounted';
|
||||
}} else if (stillLoading) {{
|
||||
state = 'waiting_on_database';
|
||||
}} else if (hasSliceContainer && hasUnpaintedEchart) {{
|
||||
state = 'mounted_unpainted';
|
||||
}} else {{
|
||||
state = 'nothing_mounted';
|
||||
}}
|
||||
@@ -208,6 +236,11 @@ FIND_CHART_HOLDER_STATES_JS = f"""
|
||||
) !== null) {{
|
||||
return {{ chartId, state: 'empty' }};
|
||||
}}
|
||||
if (hasSliceContainer && holder.querySelector(
|
||||
'{ECHARTS_UNPAINTED_HOST_SELECTOR}'
|
||||
) !== null) {{
|
||||
return {{ chartId, state: 'mounted_unpainted' }};
|
||||
}}
|
||||
if (hasSliceContainer) {{
|
||||
return {{ chartId, state: 'rendered' }};
|
||||
}}
|
||||
@@ -237,7 +270,8 @@ CHART_CONTAINER_READY_JS = f"""
|
||||
const chart = document.querySelector('.chart-container');
|
||||
return chart !== null
|
||||
&& chart.querySelector('{LOADING_SELECTOR}') === null
|
||||
&& chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
|
||||
&& chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null
|
||||
&& chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') === null;
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -249,6 +283,9 @@ CHART_CONTAINER_STATE_JS = f"""
|
||||
const chart = document.querySelector('.chart-container');
|
||||
if (chart === null) {{ return 'missing'; }}
|
||||
if (chart.querySelector('{LOADING_SELECTOR}') !== null) {{ return 'loading'; }}
|
||||
if (chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') !== null) {{
|
||||
return 'mounted_unpainted';
|
||||
}}
|
||||
if (chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null) {{
|
||||
return 'terminal';
|
||||
}}
|
||||
|
||||
@@ -646,6 +646,65 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboard_derives_stale_filter_scope(self):
|
||||
"""
|
||||
Dashboard API: ``chartsInScope`` is derived from the layout, not read
|
||||
back from the stored cache (sc-116923).
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
slices = db.session.query(Slice).limit(2).all()
|
||||
positions = {
|
||||
"ROOT_ID": {"id": "ROOT_ID", "type": "ROOT", "children": ["GRID_ID"]},
|
||||
"GRID_ID": {"id": "GRID_ID", "type": "GRID", "parents": ["ROOT_ID"]},
|
||||
}
|
||||
for slc in slices:
|
||||
positions[f"CHART-{slc.id}"] = {
|
||||
"id": f"CHART-{slc.id}",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": slc.id},
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
}
|
||||
# A scope naming charts the dashboard does not contain - the state every
|
||||
# seeded and imported dashboard starts in.
|
||||
stored_metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"name": "Region",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [90001, 90002],
|
||||
"tabsInScope": ["TAB-gone"],
|
||||
}
|
||||
]
|
||||
}
|
||||
dashboard = self.insert_dashboard(
|
||||
"scope-cache",
|
||||
"scope-cache",
|
||||
[admin.id],
|
||||
slices=slices,
|
||||
position_json=json.dumps(positions),
|
||||
json_metadata=json.dumps(stored_metadata),
|
||||
)
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.get_assert_metric(f"api/v1/dashboard/{dashboard.id}", "get")
|
||||
assert rv.status_code == 200
|
||||
|
||||
response_metadata = json.loads(
|
||||
json.loads(rv.data.decode("utf-8"))["result"]["json_metadata"]
|
||||
)
|
||||
native_filter = response_metadata["native_filter_configuration"][0]
|
||||
assert sorted(native_filter["chartsInScope"]) == sorted(
|
||||
slc.id for slc in slices
|
||||
)
|
||||
assert native_filter["tabsInScope"] == []
|
||||
assert native_filter["name"] == "Region"
|
||||
# Deriving is read-only; the stored document is left alone.
|
||||
assert json.loads(dashboard.json_metadata) == stored_metadata
|
||||
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboard_with_columns(self):
|
||||
"""
|
||||
Dashboard API: Test get dashboard with column selection via q param
|
||||
|
||||
@@ -74,6 +74,7 @@ def test_validate_success(
|
||||
mock_parameters: OAuth2ProviderResponseSchema,
|
||||
) -> None:
|
||||
mocker.patch("superset.utils.oauth2.decode_oauth2_state", return_value=mock_state)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mocker.patch.object(
|
||||
DatabaseUserOAuth2TokensDAO,
|
||||
"get_database",
|
||||
@@ -95,6 +96,7 @@ def test_validate_database_not_found(
|
||||
"superset.utils.oauth2.decode_oauth2_state",
|
||||
return_value={"database_id": 999},
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mocker.patch.object(DatabaseUserOAuth2TokensDAO, "get_database", return_value=None)
|
||||
|
||||
command = OAuth2StoreTokenCommand(mock_parameters)
|
||||
@@ -120,6 +122,7 @@ def test_run_success(
|
||||
"get_database",
|
||||
return_value=mock_database,
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mocker.patch.object(
|
||||
DatabaseUserOAuth2TokensDAO,
|
||||
"find_one_or_none",
|
||||
@@ -155,6 +158,7 @@ def test_run_logs_token_exchange_failure(
|
||||
"get_database",
|
||||
return_value=mock_database,
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mock_database.db_engine_spec.get_oauth2_token.side_effect = HTTPError(
|
||||
"provider-payload-sentinel"
|
||||
)
|
||||
@@ -188,6 +192,7 @@ def test_run_existing_token(
|
||||
"get_database",
|
||||
return_value=mock_database,
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
existing_token = MagicMock()
|
||||
mocker.patch.object(
|
||||
DatabaseUserOAuth2TokensDAO,
|
||||
@@ -208,3 +213,23 @@ def test_run_existing_token(
|
||||
assert result == "new_token"
|
||||
mock_delete.assert_called_once_with([existing_token])
|
||||
mock_create.assert_called_once()
|
||||
|
||||
|
||||
def test_validate_rejects_state_not_bound_to_session(
|
||||
mocker: MockerFixture,
|
||||
mock_parameters: OAuth2ProviderResponseSchema,
|
||||
) -> None:
|
||||
"""
|
||||
The callback must only store tokens for the user who initiated the
|
||||
dance: a state minted for another user, or presented without an
|
||||
authenticated session, is rejected before any token exchange.
|
||||
"""
|
||||
command = OAuth2StoreTokenCommand(mock_parameters)
|
||||
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=2)
|
||||
with pytest.raises(OAuth2Error):
|
||||
command.validate()
|
||||
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=None)
|
||||
with pytest.raises(OAuth2Error):
|
||||
command.validate()
|
||||
|
||||
@@ -46,6 +46,7 @@ from superset.commands.report.exceptions import (
|
||||
ReportScheduleXlsxFailedError,
|
||||
)
|
||||
from superset.commands.report.execute import (
|
||||
_should_build_execution_context,
|
||||
BaseReportState,
|
||||
log_report_delivery_phase,
|
||||
persist_owned_report_execution_terminal_error,
|
||||
@@ -3747,6 +3748,8 @@ def test_success_state_send_error_logs_and_reraises(
|
||||
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
|
||||
)
|
||||
mocker.patch.object(state, "send", side_effect=RuntimeError("send boom"))
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
|
||||
mocker.patch.object(state, "send_error")
|
||||
mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
|
||||
with pytest.raises(RuntimeError, match="send boom"):
|
||||
@@ -3808,6 +3811,46 @@ def test_get_notification_content_alert_no_flag_skips_attachment(
|
||||
assert content.text is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schedule_type", "report_format", "attach_flag", "expected"),
|
||||
[
|
||||
# Reports always run under an execution context, regardless of format.
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.PNG, False, True),
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.PDF, False, True),
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.CSV, False, True),
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.TEXT, False, True),
|
||||
# Alerts that deliver a rendered screenshot fail closed only when the
|
||||
# ALERTS_ATTACH_REPORTS flag is on (otherwise no artifact is attached).
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PNG, True, True),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PDF, True, True),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PNG, False, False),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PDF, False, False),
|
||||
# CSV/text/xlsx alerts never deliver a rendered screenshot; they stay
|
||||
# lenient even with the attach flag on.
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.CSV, True, False),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.TEXT, True, False),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.XLSX, True, False),
|
||||
],
|
||||
)
|
||||
@patch("superset.commands.report.execute.feature_flag_manager")
|
||||
def test_should_build_execution_context(
|
||||
mock_ff: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
schedule_type: ReportScheduleType,
|
||||
report_format: ReportDataFormat,
|
||||
attach_flag: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""Only reports and rendered-screenshot alerts run fail closed under a
|
||||
ReportExecutionContext; CSV/text alerts and flag-off alerts stay lenient."""
|
||||
mock_ff.is_feature_enabled.return_value = attach_flag
|
||||
model = mocker.Mock(spec=ReportSchedule)
|
||||
model.type = schedule_type
|
||||
model.report_format = report_format
|
||||
|
||||
assert _should_build_execution_context(model) is expected
|
||||
|
||||
|
||||
def test_create_log_success_commits(mocker: MockerFixture) -> None:
|
||||
"""Successful create_log creates a log entry and commits."""
|
||||
schedule = mocker.Mock(spec=ReportSchedule)
|
||||
@@ -4217,6 +4260,139 @@ def test_success_state_error_logged_when_send_error_raises(
|
||||
assert ReportState.ERROR in states
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schedule_type",
|
||||
[ReportScheduleType.REPORT, ReportScheduleType.ALERT],
|
||||
)
|
||||
def test_success_state_send_failure_notifies_owner(
|
||||
mocker: MockerFixture,
|
||||
schedule_type: ReportScheduleType,
|
||||
) -> None:
|
||||
"""A delivery failure from the Success/Grace path must notify the owner,
|
||||
mirroring the first-run (ReportNotTriggeredErrorState) path — otherwise a
|
||||
previously-successful schedule fails silently (e.g. once a screenshot
|
||||
capture starts failing closed)."""
|
||||
state = _make_state_instance(
|
||||
mocker, ReportSuccessState, schedule_type=schedule_type
|
||||
)
|
||||
# No retries configured (the default), so _handle_retry_or_error returns
|
||||
# False immediately without sending anything.
|
||||
mocker.patch.object(state, "is_in_grace_period", return_value=False)
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
|
||||
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
mock_send_error = mocker.patch.object(state, "send_error")
|
||||
if schedule_type == ReportScheduleType.ALERT:
|
||||
mocker.patch(
|
||||
"superset.commands.report.execute.AlertCommand"
|
||||
).return_value.run.return_value = (True, "triggered")
|
||||
mocker.patch.object(
|
||||
state,
|
||||
"send",
|
||||
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
|
||||
)
|
||||
|
||||
with pytest.raises(ReportScheduleScreenshotFailedError, match="blank screenshot"):
|
||||
state.next()
|
||||
|
||||
mock_send_error.assert_called_once()
|
||||
# The owner-notification path must also persist a terminal ERROR state,
|
||||
# not leave the schedule stuck in WORKING (mirrors how the grace-period
|
||||
# sibling test asserts the recorded terminal state).
|
||||
assert mock_update.call_args_list[-1].args[0] == ReportState.ERROR
|
||||
|
||||
|
||||
def test_success_state_send_failure_skips_notification_in_error_grace(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When inside the error grace period, the Success/Grace path logs ERROR
|
||||
but suppresses the (throttled) error notification."""
|
||||
state = _make_state_instance(
|
||||
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
|
||||
)
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=True)
|
||||
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
mock_send_error = mocker.patch.object(state, "send_error")
|
||||
mocker.patch.object(
|
||||
state,
|
||||
"send",
|
||||
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
|
||||
)
|
||||
|
||||
with pytest.raises(ReportScheduleScreenshotFailedError):
|
||||
state.next()
|
||||
|
||||
mock_send_error.assert_not_called()
|
||||
states = [call.args[0] for call in mock_update.call_args_list]
|
||||
assert ReportState.ERROR in states
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_kind", "expected_message"),
|
||||
[
|
||||
("superset_errors", "smtp down;retry failed"),
|
||||
("generic", "smtp down"),
|
||||
],
|
||||
)
|
||||
def test_success_state_send_error_failure_overwrites_marker(
|
||||
mocker: MockerFixture,
|
||||
failure_kind: str,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
"""When the Success/Grace path's own error notification fails, the
|
||||
placeholder marker is overwritten with the real failure message before
|
||||
ERROR is logged -- mirroring the first-run (ReportNotTriggeredErrorState)
|
||||
path. A SupersetErrorsException contributes its joined error messages; any
|
||||
other exception contributes its ``str()``."""
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetErrorsException
|
||||
|
||||
if failure_kind == "superset_errors":
|
||||
send_error_exc: Exception = SupersetErrorsException(
|
||||
[
|
||||
SupersetError(
|
||||
message="smtp down",
|
||||
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
SupersetError(
|
||||
message="retry failed",
|
||||
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
send_error_exc = RuntimeError("smtp down")
|
||||
|
||||
state = _make_state_instance(
|
||||
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
|
||||
)
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
|
||||
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
mock_send_error = mocker.patch.object(
|
||||
state, "send_error", side_effect=send_error_exc
|
||||
)
|
||||
mocker.patch.object(
|
||||
state,
|
||||
"send",
|
||||
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
|
||||
)
|
||||
|
||||
with pytest.raises(ReportScheduleScreenshotFailedError, match="blank screenshot"):
|
||||
state.next()
|
||||
|
||||
mock_send_error.assert_called_once()
|
||||
# The placeholder marker must be replaced by the real notification failure
|
||||
# before the terminal ERROR row is written.
|
||||
final_call = mock_update.call_args_list[-1]
|
||||
assert final_call.args[0] == ReportState.ERROR
|
||||
assert final_call.kwargs.get("error_message") == expected_message
|
||||
assert (
|
||||
final_call.kwargs.get("error_message")
|
||||
!= REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
|
||||
)
|
||||
|
||||
|
||||
def test_get_url_for_csv_uses_post_processed_type(
|
||||
app: SupersetApp,
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -21,6 +21,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import g
|
||||
from jinja2.exceptions import TemplateSyntaxError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.sql_lab.streaming_export_command import (
|
||||
@@ -133,6 +134,24 @@ def test_validate_access_denied(mock_db, mock_query):
|
||||
assert exc_info.value.status == 403
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.streaming_export_command.db")
|
||||
def test_validate_jinja_template_error(mock_db, mock_query):
|
||||
"""Test validate converts a Jinja TemplateError into a 400 error."""
|
||||
mock_query_result = mock_db.session.query.return_value.filter_by.return_value
|
||||
mock_query_result.one_or_none.return_value = mock_query
|
||||
mock_query.raise_for_access.side_effect = TemplateSyntaxError(
|
||||
"unexpected end of template", lineno=1
|
||||
)
|
||||
|
||||
command = StreamingSqlResultExportCommand("test_client_123")
|
||||
|
||||
with pytest.raises(SupersetErrorException) as exc_info:
|
||||
command.validate()
|
||||
|
||||
assert exc_info.value.error.error_type == SupersetErrorType.GENERIC_COMMAND_ERROR
|
||||
assert exc_info.value.status == 400
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.streaming_export_command.db")
|
||||
def test_validate_success(mock_db, mock_query):
|
||||
"""Test successful validation."""
|
||||
|
||||
@@ -27,6 +27,7 @@ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultTyp
|
||||
from superset.common.chart_data_timing import QueryDataResult, QueryTiming
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.common.query_context_processor import QueryContextProcessor
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
from superset.utils.core import GenericDataType
|
||||
from superset.utils.date_parser import get_past_or_future
|
||||
|
||||
@@ -98,6 +99,25 @@ def processor(mock_query_context):
|
||||
return processor
|
||||
|
||||
|
||||
def test_query_cache_key_binds_annotation_data_to_requesting_user(processor):
|
||||
"""The cache key for annotated queries must differ per requesting user."""
|
||||
query_obj = MagicMock()
|
||||
query_obj.annotation_layers = [{"sourceType": "NATIVE", "name": "a", "value": 1}]
|
||||
with (
|
||||
patch(
|
||||
"superset.common.query_context_processor.get_user_id",
|
||||
side_effect=[1, 2],
|
||||
),
|
||||
patch("superset.common.query_context_processor.security_manager"),
|
||||
):
|
||||
processor.query_cache_key(query_obj)
|
||||
processor.query_cache_key(query_obj)
|
||||
contexts = [
|
||||
call.kwargs["annotation_context"] for call in query_obj.cache_key.call_args_list
|
||||
]
|
||||
assert contexts[0] != contexts[1]
|
||||
|
||||
|
||||
def test_get_data_table_like(processor, mock_query_context):
|
||||
df = pd.DataFrame({"col1": [1, 2, 3], "col2": ["a", "b", "c"]})
|
||||
coltypes = [GenericDataType.NUMERIC, GenericDataType.STRING]
|
||||
@@ -2377,3 +2397,26 @@ def test_relative_offset_preserves_inner_bounds(
|
||||
# for #40501. Without the fix, inner_from/to_dttm == shifted dates.
|
||||
assert captured[0]["inner_from_dttm"] == pd.Timestamp("2026-05-01")
|
||||
assert captured[0]["inner_to_dttm"] == pd.Timestamp("2026-05-28")
|
||||
|
||||
|
||||
def test_get_native_annotation_data_requires_annotation_read_access():
|
||||
"""Native annotation layers are only served to users who can read them."""
|
||||
query_obj = MagicMock()
|
||||
query_obj.annotation_layers = [{"sourceType": "NATIVE", "name": "a", "value": 1}]
|
||||
with (
|
||||
patch(
|
||||
"superset.common.query_context_processor.security_manager"
|
||||
) as security_manager_mock,
|
||||
patch(
|
||||
"superset.common.query_context_processor.AnnotationLayerDAO.find_by_ids",
|
||||
return_value=[],
|
||||
) as find_by_ids_mock,
|
||||
):
|
||||
# ``can_access`` is synchronous; force a plain Mock so the patched
|
||||
# manager doesn't hand back a truthy coroutine that slips past the
|
||||
# ``not can_access(...)`` guard.
|
||||
security_manager_mock.can_access = MagicMock(return_value=False)
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
QueryContextProcessor.get_native_annotation_data(query_obj)
|
||||
security_manager_mock.can_access.assert_called_once_with("can_read", "Annotation")
|
||||
find_by_ids_mock.assert_not_called()
|
||||
|
||||
@@ -19,6 +19,7 @@ from collections.abc import Iterator
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm.session import Session
|
||||
from superset_core.tasks.types import TaskProperties, TaskScope, TaskStatus
|
||||
|
||||
@@ -395,9 +396,15 @@ def test_remove_subscriber_not_subscribed(session_with_task: Session) -> None:
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_status(session_with_task: Session) -> None:
|
||||
def test_get_status(session_with_task: Session, mocker: MockerFixture) -> None:
|
||||
"""Test get_status returns status string when task found by UUID"""
|
||||
from superset.daos.tasks import TaskDAO
|
||||
from superset.models.task_subscribers import TaskSubscriber
|
||||
|
||||
# get_status enforces the TaskFilter, so the polling user must be
|
||||
# authenticated and subscribed to see the task.
|
||||
mocker.patch("superset.tasks.filters.get_user_id", return_value=TEST_USER_ID)
|
||||
mocker.patch("superset.security_manager.is_admin", return_value=False)
|
||||
|
||||
task = create_task(
|
||||
session_with_task,
|
||||
@@ -405,6 +412,8 @@ def test_get_status(session_with_task: Session) -> None:
|
||||
task_key="status-task",
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
)
|
||||
session_with_task.add(TaskSubscriber(task_id=task.id, user_id=TEST_USER_ID))
|
||||
session_with_task.flush()
|
||||
|
||||
result = TaskDAO.get_status(task.uuid)
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from superset.dashboards.filter_scope import (
|
||||
derive_json_metadata,
|
||||
derive_metadata_scopes,
|
||||
derive_scopes,
|
||||
)
|
||||
from superset.utils import json
|
||||
|
||||
# Two charts in a tab, one chart outside it.
|
||||
POSITION_DATA: dict[str, Any] = {
|
||||
"ROOT_ID": {"id": "ROOT_ID", "type": "ROOT", "children": ["GRID_ID"]},
|
||||
"CHART-outside": {
|
||||
"id": "CHART-outside",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": 1},
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"CHART-in-tab": {
|
||||
"id": "CHART-in-tab",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": 2},
|
||||
"parents": ["ROOT_ID", "GRID_ID", "TABS-1", "TAB-1"],
|
||||
},
|
||||
"CHART-also-in-tab": {
|
||||
"id": "CHART-also-in-tab",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": 3},
|
||||
"parents": ["ROOT_ID", "GRID_ID", "TABS-1", "TAB-1"],
|
||||
},
|
||||
"MARKDOWN-1": {"id": "MARKDOWN-1", "type": "MARKDOWN", "parents": ["ROOT_ID"]},
|
||||
}
|
||||
CHART_IDS = [1, 2, 3]
|
||||
|
||||
|
||||
def test_stale_charts_in_scope_is_replaced() -> None:
|
||||
"""The reported symptom: a scope cache naming charts that are not present."""
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [7, 17, 23],
|
||||
"tabsInScope": ["TAB-gone"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [1, 2, 3]
|
||||
assert derived["native_filter_configuration"][0]["tabsInScope"] == ["TAB-1"]
|
||||
|
||||
|
||||
def test_scope_narrowed_to_a_tab() -> None:
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["TAB-1"], "excluded": [3]},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [2]
|
||||
assert derived["native_filter_configuration"][0]["tabsInScope"] == ["TAB-1"]
|
||||
|
||||
|
||||
def test_dividers_and_unscoped_items() -> None:
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{"id": "NATIVE_FILTER_DIVIDER-1", "chartsInScope": [7], "tabsInScope": []},
|
||||
{"id": "DIVIDER-1", "type": "DIVIDER", "chartsInScope": [7]},
|
||||
# A legacy chart customization targets a chart directly and only
|
||||
# gains a scope once the client migrates it.
|
||||
{"id": "CHART_CUSTOMIZATION-1", "chartId": 7, "chartsInScope": [7]},
|
||||
]
|
||||
}
|
||||
|
||||
config = derive_scopes(metadata, POSITION_DATA, CHART_IDS)[
|
||||
"native_filter_configuration"
|
||||
]
|
||||
|
||||
assert config[0]["chartsInScope"] == []
|
||||
assert config[1]["chartsInScope"] == []
|
||||
assert config[2]["chartsInScope"] == [7]
|
||||
|
||||
|
||||
def test_chart_configuration_drops_charts_not_on_the_dashboard() -> None:
|
||||
metadata = {
|
||||
"global_chart_configuration": {
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [7, 17],
|
||||
},
|
||||
"chart_configuration": {
|
||||
"1": {"id": 1, "crossFilters": {"scope": "global", "chartsInScope": [17]}},
|
||||
"2": {
|
||||
"id": 2,
|
||||
"crossFilters": {
|
||||
"scope": {"rootPath": ["TAB-1"], "excluded": []},
|
||||
"chartsInScope": [23],
|
||||
},
|
||||
},
|
||||
"84": {"id": 84, "crossFilters": {"scope": "global", "chartsInScope": [7]}},
|
||||
},
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert derived["global_chart_configuration"]["chartsInScope"] == [1, 2, 3]
|
||||
assert list(derived["chart_configuration"]) == ["1", "2"]
|
||||
# A globally scoped chart emits to every other chart, never to itself.
|
||||
assert derived["chart_configuration"]["1"]["crossFilters"]["chartsInScope"] == [
|
||||
2,
|
||||
3,
|
||||
]
|
||||
assert derived["chart_configuration"]["2"]["crossFilters"]["chartsInScope"] == [
|
||||
2,
|
||||
3,
|
||||
]
|
||||
|
||||
|
||||
def test_selected_layers_target_their_chart_directly() -> None:
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {
|
||||
"rootPath": ["TAB-1"],
|
||||
"excluded": [1],
|
||||
"selectedLayers": ["chart-1-layer-0"],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
# Chart 1 is excluded and outside the rootPath, but a layer selection wins.
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_key_order_and_untouched_keys_are_kept() -> None:
|
||||
metadata = {
|
||||
"color_scheme": "supersetColors",
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"chartsInScope": [7],
|
||||
"name": "Region",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
}
|
||||
],
|
||||
"refresh_frequency": 0,
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert list(derived) == list(metadata)
|
||||
assert list(derived["native_filter_configuration"][0]) == [
|
||||
"id",
|
||||
"chartsInScope",
|
||||
"name",
|
||||
"scope",
|
||||
"tabsInScope",
|
||||
]
|
||||
assert derived["color_scheme"] == "supersetColors"
|
||||
assert derived["refresh_frequency"] == 0
|
||||
|
||||
|
||||
def test_derive_metadata_scopes_orders_by_dashboard_charts() -> None:
|
||||
dashboard = SimpleNamespace(
|
||||
position=POSITION_DATA,
|
||||
slices=[SimpleNamespace(id=3), SimpleNamespace(id=1), SimpleNamespace(id=2)],
|
||||
)
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_metadata_scopes(dashboard, metadata) # type: ignore[arg-type]
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [3, 1, 2]
|
||||
|
||||
|
||||
def test_derive_json_metadata_round_trip() -> None:
|
||||
dashboard = SimpleNamespace(position=POSITION_DATA, slices=[SimpleNamespace(id=1)])
|
||||
stored = json.dumps(
|
||||
{
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [61, 62],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
derived = json.loads(derive_json_metadata(dashboard, stored)) # type: ignore[arg-type]
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [1]
|
||||
|
||||
|
||||
def test_derive_json_metadata_passes_through_unparsable_metadata() -> None:
|
||||
dashboard = SimpleNamespace(position={}, slices=[])
|
||||
|
||||
assert derive_json_metadata(dashboard, "not json") == "not json" # type: ignore[arg-type]
|
||||
assert derive_json_metadata(dashboard, "[]") == "[]" # type: ignore[arg-type]
|
||||
@@ -710,6 +710,10 @@ def test_oauth2_happy_path(
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.database.oauth2.get_user_id",
|
||||
return_value=1,
|
||||
)
|
||||
state: OAuth2State = {
|
||||
"user_id": 1,
|
||||
"database_id": 1,
|
||||
@@ -786,6 +790,10 @@ def test_oauth2_permissions(
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.database.oauth2.get_user_id",
|
||||
return_value=1,
|
||||
)
|
||||
state: OAuth2State = {
|
||||
"user_id": 1,
|
||||
"database_id": 1,
|
||||
@@ -867,6 +875,10 @@ def test_oauth2_multiple_tokens(
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.database.oauth2.get_user_id",
|
||||
return_value=1,
|
||||
)
|
||||
state: OAuth2State = {
|
||||
"user_id": 1,
|
||||
"database_id": 1,
|
||||
|
||||
@@ -2239,3 +2239,79 @@ def test_import_restore_blocked_by_active_twin_at_incoming_identity(
|
||||
assert "another active dataset" in str(excinfo.value)
|
||||
# Check-before-mutate: the failed import leaves the row soft-deleted.
|
||||
assert existing.deleted_at is not None
|
||||
|
||||
|
||||
def test_peer_validating_connection_blocks_rebound_peer() -> None:
|
||||
"""
|
||||
The import fetch validates the connected peer address, so a hostname that
|
||||
passes ``is_safe_host`` and then re-resolves to an internal address (DNS
|
||||
rebinding) is rejected before any request bytes are sent.
|
||||
"""
|
||||
from http.client import HTTPConnection
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from superset.commands.dataset.exceptions import DatasetForbiddenDataURI
|
||||
from superset.commands.dataset.importers.v1.utils import (
|
||||
_PeerValidatingHTTPConnection,
|
||||
)
|
||||
|
||||
sock = MagicMock()
|
||||
sock.getpeername.return_value = ("169.254.169.254", 80)
|
||||
|
||||
with patch.object(
|
||||
HTTPConnection, "connect", lambda self: setattr(self, "sock", sock)
|
||||
):
|
||||
conn = _PeerValidatingHTTPConnection("rebinder.example.com")
|
||||
with pytest.raises(DatasetForbiddenDataURI):
|
||||
conn.connect()
|
||||
|
||||
|
||||
def test_load_data_disables_proxy_when_internal_urls_disallowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
``load_data`` builds its opener with an explicit no-proxy handler when
|
||||
internal data URLs are disallowed, so a configured HTTP(S) proxy can't
|
||||
intercept the connection the peer check validates.
|
||||
"""
|
||||
from superset.commands.dataset.importers.v1.utils import load_data
|
||||
|
||||
current_app.config["DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS"] = False
|
||||
|
||||
mocker.patch("superset.commands.dataset.importers.v1.utils.validate_data_uri")
|
||||
mocker.patch(
|
||||
"superset.examples.helpers.normalize_example_data_url",
|
||||
side_effect=lambda uri: uri,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.importers.v1.utils._convert_temporal_columns"
|
||||
)
|
||||
mocker.patch("superset.commands.dataset.importers.v1.utils.db.session.connection")
|
||||
mock_df = Mock()
|
||||
mock_df.keys.return_value = []
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.importers.v1.utils.pd.read_csv",
|
||||
return_value=mock_df,
|
||||
)
|
||||
mock_opener = Mock()
|
||||
mock_opener.open.return_value = io.BytesIO(b"")
|
||||
mock_build_opener = mocker.patch(
|
||||
"superset.commands.dataset.importers.v1.utils.request.build_opener",
|
||||
return_value=mock_opener,
|
||||
)
|
||||
|
||||
dataset = Mock(spec=SqlaTable)
|
||||
dataset.columns = []
|
||||
dataset.table_name = "my_table"
|
||||
dataset.schema = None
|
||||
|
||||
database = Mock(spec=Database)
|
||||
database.sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
|
||||
|
||||
load_data("https://example.org/data.csv", dataset, database)
|
||||
|
||||
handlers = mock_build_opener.call_args.args
|
||||
assert any(
|
||||
isinstance(handler, request.ProxyHandler) and not handler.proxies # type: ignore[attr-defined]
|
||||
for handler in handlers
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine.default import DefaultDialect
|
||||
|
||||
from superset.db_engine_specs import get_available_engine_specs
|
||||
|
||||
@@ -50,6 +51,92 @@ def test_get_available_engine_specs(mocker: MockerFixture) -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_get_available_engine_specs_skips_malformed_dialect_entry_point(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
A third-party ``sqlalchemy.dialects`` entry point that loads successfully but
|
||||
does not resolve to a usable dialect (e.g. a module with no ``name`` or a
|
||||
named class that does not implement the dialect contract) must be skipped.
|
||||
|
||||
Regression test: an unguarded ``dialect.name`` there aborted the whole
|
||||
enumeration with ``AttributeError``, which 500s every page that builds the
|
||||
bootstrap payload (e.g. ``/welcome/``), not just that one connector.
|
||||
"""
|
||||
import types
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.load_engine_specs",
|
||||
return_value=iter([]),
|
||||
)
|
||||
|
||||
malformed_ep = mocker.MagicMock()
|
||||
malformed_ep.name = "bogus"
|
||||
malformed_ep.value = "bogus_pkg:base"
|
||||
# ``ep.load()`` returns a module (no ``name`` attribute), as a real
|
||||
# ``name = pkg:submodule`` entry point would.
|
||||
malformed_ep.load.return_value = types.ModuleType("bogus_pkg.base")
|
||||
|
||||
named_but_invalid_ep = mocker.MagicMock()
|
||||
named_but_invalid_ep.name = "named_bogus"
|
||||
named_but_invalid_ep.value = "bogus_pkg:NamedButInvalidDialect"
|
||||
named_but_invalid_ep.load.return_value = type(
|
||||
"NamedButInvalidDialect",
|
||||
(),
|
||||
{"name": "bogus", "driver": "bogus"},
|
||||
)
|
||||
|
||||
def entry_points(group: str) -> list[object]:
|
||||
return (
|
||||
[malformed_ep, named_but_invalid_ep]
|
||||
if group == "sqlalchemy.dialects"
|
||||
else []
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.entry_points",
|
||||
side_effect=entry_points,
|
||||
)
|
||||
warning = mocker.patch("superset.db_engine_specs.logger.warning")
|
||||
|
||||
# Must not raise (previously ``AttributeError`` on ``dialect.name``).
|
||||
available = get_available_engine_specs()
|
||||
|
||||
assert isinstance(available, dict)
|
||||
# The malformed entry point is skipped with a warning that identifies it.
|
||||
assert any("bogus" in str(call) for call in warning.call_args_list)
|
||||
assert any("named_bogus" in str(call) for call in warning.call_args_list)
|
||||
|
||||
|
||||
def test_get_available_engine_specs_keeps_valid_third_party_dialect(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A valid SQLAlchemy 2.0-style dialect is included without calling dbapi()."""
|
||||
import sqlalchemy.dialects
|
||||
|
||||
from superset.db_engine_specs.sqlite import SqliteEngineSpec
|
||||
|
||||
class ValidDialect(DefaultDialect):
|
||||
name = "sqlite"
|
||||
driver = "valid_driver"
|
||||
|
||||
mocker.patch.object(sqlalchemy.dialects, "__all__", [])
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.load_engine_specs",
|
||||
return_value=iter([SqliteEngineSpec]),
|
||||
)
|
||||
entry_point = mocker.MagicMock()
|
||||
entry_point.load.return_value = ValidDialect
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.entry_points",
|
||||
return_value=[entry_point],
|
||||
)
|
||||
|
||||
available = get_available_engine_specs()
|
||||
|
||||
assert available[SqliteEngineSpec] == {"valid_driver"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"app",
|
||||
[{"DBS_AVAILABLE_DENYLIST": {"databricks": {"pyhive", "pyodbc"}}}],
|
||||
|
||||
@@ -78,6 +78,32 @@ def test_ephemeral_get_delegates_to_dao(
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.api.ExtensionEphemeralDAO")
|
||||
@patch("superset.extensions.storage.utils.get_extensions")
|
||||
def test_ephemeral_get_response_is_marked_no_store(
|
||||
mock_get_ext: MagicMock, mock_dao: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""Stored values are scoped to the requesting user, so responses built via
|
||||
`response()` must never be cached (e.g. by a shared/CDN cache)."""
|
||||
mock_get_ext.return_value = {"acme.dashboard": MagicMock()}
|
||||
Babel(app)
|
||||
app.appbuilder = MagicMock()
|
||||
app.appbuilder.sm.is_item_public.return_value = True
|
||||
mock_dao.get_raw.return_value = (get_codec("json").encode({"data": 42}), "json")
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/v1/extensions/acme/dashboard/storage/ephemeral/my-key"
|
||||
):
|
||||
g.user = MagicMock(id=7)
|
||||
|
||||
body, status_code = ExtensionStorageRestApi().get_ephemeral(
|
||||
"acme", "dashboard", "my-key"
|
||||
)
|
||||
|
||||
assert status_code == 200
|
||||
assert body.cache_control.no_store is True
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.api.ExtensionEphemeralDAO")
|
||||
@patch("superset.extensions.storage.utils.get_extensions")
|
||||
def test_ephemeral_get_returns_none_when_entry_missing(
|
||||
|
||||
@@ -103,6 +103,17 @@ def test_unrelated_path_is_not_intercepted() -> None:
|
||||
assert headers == upstream
|
||||
|
||||
|
||||
def test_storage_endpoints_are_not_intercepted() -> None:
|
||||
"""Per-user storage responses must keep Vary: Cookie for shared caches."""
|
||||
upstream = [("Vary", "Accept-Encoding, Cookie")]
|
||||
for path in (
|
||||
"/api/v1/extensions/acme/my-ext/storage/ephemeral/some-key",
|
||||
"/api/v1/extensions/acme/my-ext/storage/persistent/some-key",
|
||||
):
|
||||
headers = call_middleware(path, upstream)
|
||||
assert headers == upstream
|
||||
|
||||
|
||||
# --- Vary stripping logic ---
|
||||
|
||||
|
||||
|
||||
@@ -1096,6 +1096,34 @@ def test_metric_macro_with_dataset_id(mocker: MockerFixture) -> None:
|
||||
mock_get_form_data.assert_not_called()
|
||||
|
||||
|
||||
def test_metric_macro_guest_user_dataset_out_of_scope(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that ``metric_macro`` denies a guest user a dataset that is not
|
||||
reachable through any dashboard their guest token grants.
|
||||
"""
|
||||
mocker.patch("superset.security_manager.is_guest_user", return_value=True)
|
||||
guest_user = mocker.MagicMock()
|
||||
guest_user.guest_token = {}
|
||||
mocker.patch(
|
||||
"superset.security_manager.get_current_guest_user_if_guest",
|
||||
return_value=guest_user,
|
||||
)
|
||||
DatasetDAO = mocker.patch("superset.daos.dataset.DatasetDAO") # noqa: N806
|
||||
DatasetDAO.find_by_id.return_value = SqlaTable(
|
||||
id=1,
|
||||
table_name="test_dataset",
|
||||
metrics=[
|
||||
SqlMetric(metric_name="count", expression="COUNT(*)"),
|
||||
],
|
||||
database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"),
|
||||
schema="my_schema",
|
||||
sql=None,
|
||||
)
|
||||
env = SandboxedEnvironment(undefined=DebugUndefined)
|
||||
with pytest.raises(DatasetNotFoundError):
|
||||
metric_macro(env, {}, "count", 1)
|
||||
|
||||
|
||||
def test_metric_macro_recursive(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test the ``metric_macro`` when the definition is recursive.
|
||||
@@ -1732,6 +1760,13 @@ def test_metric_macro_embedded_user_skips_base_filter(mocker: MockerFixture) ->
|
||||
mock_is_guest_user = mocker.patch("superset.security_manager.is_guest_user")
|
||||
mock_is_guest_user.return_value = True
|
||||
|
||||
# Dashboard-level guest scope is asserted separately; here the dataset is
|
||||
# in scope so the test can focus on the base-filter bypass.
|
||||
mocker.patch(
|
||||
"superset.jinja_context.guest_user_can_access_dataset",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
DatasetDAO = mocker.patch("superset.daos.dataset.DatasetDAO") # noqa: N806
|
||||
DatasetDAO.find_by_id.return_value = SqlaTable(
|
||||
table_name="test_dataset",
|
||||
|
||||
@@ -224,6 +224,69 @@ def test_raise_for_access_guest_user_ok_subset(
|
||||
sm.raise_for_access(query_context=query_context)
|
||||
|
||||
|
||||
def test_raise_for_access_guest_user_deck_multi_child_requires_child_datasource(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""
|
||||
The deck.gl multi-layer child leg must bind the requested datasource to
|
||||
the child chart: a valid parent/child pair does not authorize querying
|
||||
an arbitrary dataset.
|
||||
"""
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
||||
mocker.patch.object(sm, "can_access", return_value=False)
|
||||
mocker.patch.object(sm, "can_access_schema", return_value=False)
|
||||
mocker.patch.object(sm, "is_editor", return_value=False)
|
||||
mocker.patch.object(sm, "can_access_dashboard", return_value=True)
|
||||
mocker.patch.object(sm, "get_current_guest_user_if_guest", return_value=None)
|
||||
mocker.patch(
|
||||
"superset.is_feature_enabled",
|
||||
side_effect=lambda feature: feature == "EMBEDDED_SUPERSET",
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.security.manager.query_context_modified",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
child_datasource = mocker.MagicMock()
|
||||
other_datasource = mocker.MagicMock()
|
||||
|
||||
parent_slc = mocker.MagicMock()
|
||||
parent_slc.params = json.dumps({"viz_type": "deck_multi", "deck_slices": [42]})
|
||||
child_slc = mocker.MagicMock()
|
||||
child_slc.datasource = child_datasource
|
||||
|
||||
dashboard = mocker.MagicMock()
|
||||
dashboard.slices = [parent_slc]
|
||||
|
||||
query_mock = mocker.patch.object(sm.session, "query")
|
||||
query_mock.return_value.filter.return_value.one_or_none.side_effect = [
|
||||
dashboard,
|
||||
parent_slc,
|
||||
child_slc,
|
||||
dashboard,
|
||||
parent_slc,
|
||||
child_slc,
|
||||
]
|
||||
|
||||
query_context = mocker.MagicMock()
|
||||
query_context.form_data = {
|
||||
"dashboardId": 10,
|
||||
"slice_id": 42,
|
||||
"parent_slice_id": 41,
|
||||
}
|
||||
|
||||
# Requesting the child's own datasource is allowed.
|
||||
query_context.datasource = child_datasource
|
||||
sm.raise_for_access(query_context=query_context)
|
||||
|
||||
# The same chart context with any other datasource is rejected.
|
||||
query_context.datasource = other_datasource
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
sm.raise_for_access(query_context=query_context)
|
||||
|
||||
|
||||
def test_raise_for_access_guest_user_tampered_id(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
@@ -1542,6 +1605,32 @@ def test_query_context_modified_native_filter_arbitrary_saved_metric_blocked(
|
||||
assert query_context_modified(qc)
|
||||
|
||||
|
||||
def test_query_context_modified_native_filter_series_limit_terms_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A series-limit metric or series column beyond the target is modified."""
|
||||
query = SimpleNamespace(
|
||||
columns=["region"],
|
||||
metrics=[],
|
||||
groupby=[],
|
||||
series_columns=["region"],
|
||||
series_limit=5,
|
||||
series_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {"column_name": "salary"},
|
||||
"aggregate": "MAX",
|
||||
},
|
||||
)
|
||||
qc = _native_filter_ctx(mocker, [query])
|
||||
assert query_context_modified(qc)
|
||||
|
||||
query = SimpleNamespace(
|
||||
columns=["region"], metrics=[], groupby=[], series_columns=["ssn"]
|
||||
)
|
||||
qc = _native_filter_ctx(mocker, [query])
|
||||
assert query_context_modified(qc)
|
||||
|
||||
|
||||
def test_query_context_modified_native_filter_orderby_arbitrary_column_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
|
||||
@@ -272,6 +272,7 @@ def mock_dimensions() -> list[Dimension]:
|
||||
definition="orders.order_date",
|
||||
description="Date of the order",
|
||||
grain=Grains.DAY,
|
||||
verbose_name="Order date",
|
||||
),
|
||||
Dimension(
|
||||
id="products.category",
|
||||
@@ -280,6 +281,7 @@ def mock_dimensions() -> list[Dimension]:
|
||||
definition="products.category",
|
||||
description="Product category",
|
||||
grain=None,
|
||||
verbose_name="Category",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -294,6 +296,8 @@ def mock_metrics() -> list[Metric]:
|
||||
type=pa.float64(),
|
||||
definition="SUM(orders.amount)",
|
||||
description="Total revenue",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
),
|
||||
Metric(
|
||||
id="orders.count",
|
||||
@@ -301,6 +305,8 @@ def mock_metrics() -> list[Metric]:
|
||||
type=pa.int64(),
|
||||
definition="COUNT(*)",
|
||||
description="Number of orders",
|
||||
verbose_name="Order count",
|
||||
d3format=",.0f",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -481,7 +487,9 @@ def test_semantic_view_metrics(
|
||||
assert len(metrics) == 2
|
||||
assert metrics[0].metric_name == "revenue"
|
||||
assert metrics[0].expression == "SUM(orders.amount)"
|
||||
assert metrics[0].verbose_name == "Total revenue"
|
||||
assert metrics[0].description == "Total revenue"
|
||||
assert metrics[0].d3format == "$,.2f"
|
||||
assert metrics[1].metric_name == "order_count"
|
||||
|
||||
|
||||
@@ -502,10 +510,12 @@ def test_semantic_view_columns(
|
||||
assert columns[0].column_name == "order_date"
|
||||
assert columns[0].type == "date32[day]"
|
||||
assert columns[0].is_dttm is True
|
||||
assert columns[0].verbose_name == "Order date"
|
||||
assert columns[0].description == "Date of the order"
|
||||
assert columns[1].column_name == "category"
|
||||
assert columns[1].type == "string"
|
||||
assert columns[1].is_dttm is False
|
||||
assert columns[1].verbose_name == "Category"
|
||||
|
||||
|
||||
def test_semantic_view_column_names(
|
||||
@@ -632,15 +642,32 @@ def test_semantic_view_data(
|
||||
assert data["columns"][0]["type"] == "date32[day]"
|
||||
assert data["columns"][0]["is_dttm"] is True
|
||||
assert data["columns"][0]["type_generic"] == GenericDataType.TEMPORAL
|
||||
assert data["columns"][0]["verbose_name"] == "Order date"
|
||||
assert data["columns"][1]["column_name"] == "category"
|
||||
assert data["columns"][1]["type"] == "string"
|
||||
assert data["columns"][1]["type_generic"] == GenericDataType.STRING
|
||||
assert data["columns"][1]["verbose_name"] == "Category"
|
||||
|
||||
# Check metrics
|
||||
assert len(data["metrics"]) == 2
|
||||
assert data["metrics"][0]["metric_name"] == "revenue"
|
||||
assert data["metrics"][0]["expression"] == "SUM(orders.amount)"
|
||||
assert data["metrics"][0]["verbose_name"] == "Total revenue"
|
||||
assert data["metrics"][0]["d3format"] == "$,.2f"
|
||||
assert data["metrics"][1]["metric_name"] == "order_count"
|
||||
assert data["metrics"][1]["verbose_name"] == "Order count"
|
||||
assert data["metrics"][1]["d3format"] == ",.0f"
|
||||
|
||||
assert data["verbose_map"] == {
|
||||
"revenue": "Total revenue",
|
||||
"order_count": "Order count",
|
||||
"order_date": "Order date",
|
||||
"category": "Category",
|
||||
}
|
||||
assert data["column_formats"] == {
|
||||
"revenue": "$,.2f",
|
||||
"order_count": ",.0f",
|
||||
}
|
||||
|
||||
# Check column_types and column_names
|
||||
assert data["column_types"] == [
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from flask import current_app
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy import false
|
||||
|
||||
from superset.tasks.filters import TaskFilter
|
||||
|
||||
|
||||
def test_task_filter_fails_closed_for_request_without_user_id(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""
|
||||
A request-bound principal without a user id (anonymous or guest user)
|
||||
must not receive the unfiltered task list.
|
||||
"""
|
||||
mocker.patch("superset.tasks.filters.get_user_id", return_value=None)
|
||||
task_filter = TaskFilter("id", MagicMock())
|
||||
query = MagicMock()
|
||||
|
||||
with current_app.test_request_context("/api/v1/task/"):
|
||||
filtered = task_filter.apply(query, None)
|
||||
|
||||
assert filtered is not query
|
||||
query.filter.assert_called_once()
|
||||
(predicate,) = query.filter.call_args.args
|
||||
assert str(predicate) == str(false())
|
||||
@@ -170,3 +170,21 @@ def test_safe_path_with_tab_in_internal_segment(app: Flask) -> None:
|
||||
"""A tab inside a regular path segment is still a relative URL after
|
||||
stripping; it must not flip the result to safe-then-unsafe."""
|
||||
assert is_safe_redirect_url("/dashboard/1?from=tab%09inside")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"/\\evil.com", # slash-backslash
|
||||
"\\/evil.com", # backslash-slash
|
||||
"\\\\evil.com", # double backslash
|
||||
"/\\/evil.com", # slash-backslash-slash
|
||||
"/%09/\\evil.com", # browser-stripped TAB then slash-backslash
|
||||
"https:/\\evil.com", # backslash inside an absolute URL
|
||||
],
|
||||
)
|
||||
def test_unsafe_backslash_protocol_relative(app: Flask, url: str) -> None:
|
||||
"""WHATWG URL parsers treat backslashes as forward slashes in special
|
||||
schemes, so any leading mix of slash and backslash is navigated as a
|
||||
protocol-relative URL and must be rejected."""
|
||||
assert not is_safe_redirect_url(url)
|
||||
|
||||
@@ -14,11 +14,44 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import ipaddress
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.utils.network import is_safe_host
|
||||
from superset.utils.network import is_safe_host, is_safe_ip
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ip", "expected"),
|
||||
[
|
||||
# Public → safe
|
||||
("93.184.216.34", True),
|
||||
("8.8.8.8", True),
|
||||
("2606:2800:220:1:248:1893:25c8:1946", True),
|
||||
# Loopback → unsafe
|
||||
("127.0.0.1", False),
|
||||
("::1", False),
|
||||
# RFC-1918 private ranges → unsafe
|
||||
("10.0.0.1", False),
|
||||
("172.16.0.1", False),
|
||||
("192.168.0.1", False),
|
||||
# Link-local / IMDS → unsafe
|
||||
("169.254.169.254", False),
|
||||
# CGNAT (RFC 6598) → unsafe
|
||||
("100.100.100.200", False),
|
||||
# Multicast → unsafe, despite ip.is_global being True for these
|
||||
("224.0.0.1", False),
|
||||
("ff02::1", False),
|
||||
# IPv4-mapped IPv6 → unwrapped and checked against IPv4 ranges
|
||||
("::ffff:127.0.0.1", False),
|
||||
("::ffff:8.8.8.8", True),
|
||||
],
|
||||
)
|
||||
def test_is_safe_ip(ip: str, expected: bool) -> None:
|
||||
"""`is_safe_ip` must classify individual addresses directly, independent
|
||||
of hostname resolution."""
|
||||
assert is_safe_ip(ipaddress.ip_address(ip)) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -1148,3 +1148,21 @@ class TestTileWaitBudget:
|
||||
assert args[1] == i + 1 # tile index
|
||||
assert args[2] == 3 # total tiles
|
||||
assert args[-1] == " [cache_key=xyz]"
|
||||
|
||||
|
||||
def test_readiness_predicates_gate_on_unpainted_echarts_hosts() -> None:
|
||||
"""The report gate, the single-chart gate, and the diagnostics query all
|
||||
key on the ECharts paint marker so a pre-paint canvas is never captured."""
|
||||
from superset.utils.screenshot_utils import (
|
||||
CHART_CONTAINER_READY_JS,
|
||||
ECHARTS_UNPAINTED_HOST_SELECTOR,
|
||||
FIND_CHART_HOLDER_STATES_JS,
|
||||
REPORT_CHART_HOLDERS_READY_JS,
|
||||
)
|
||||
|
||||
assert (
|
||||
ECHARTS_UNPAINTED_HOST_SELECTOR == ".echarts-host:not(.echarts-render-finished)"
|
||||
)
|
||||
assert ECHARTS_UNPAINTED_HOST_SELECTOR in REPORT_CHART_HOLDERS_READY_JS
|
||||
assert ECHARTS_UNPAINTED_HOST_SELECTOR in CHART_CONTAINER_READY_JS
|
||||
assert "mounted_unpainted" in FIND_CHART_HOLDER_STATES_JS
|
||||
|
||||
Reference in New Issue
Block a user