diff --git a/UPDATING.md b/UPDATING.md index 450f6881ca73..02f4bfaa66fc 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,6 +24,8 @@ assists people when migrating to a new version. ## Next +- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests. + ### OAuth2 database callback metrics include their outcome The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with diff --git a/docs/admin_docs/security/security.mdx b/docs/admin_docs/security/security.mdx index c659d252dfa0..ae5ad72be3ff 100644 --- a/docs/admin_docs/security/security.mdx +++ b/docs/admin_docs/security/security.mdx @@ -400,7 +400,7 @@ Once enabled, each user manages their own keys from their profile page: 1. Open the user menu (top-right) and click **Info** to navigate to the User Info page 2. Expand the **API Keys** section 3. Click **+ API Key** -4. Enter a name and (optionally) an expiration date +4. Enter a name and optionally select resource scopes 5. Copy the generated token — it is shown only once Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys. @@ -415,6 +415,18 @@ Authorization: Bearer This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key. +#### API Key Scopes + +The creation dialog can restrict an API key to MCP resource actions such as +`superset:dashboard:read` or `superset:chart:write`. A scope is an additional +restriction: it never grants a permission that the creating user does not +already have through Superset RBAC. Write scopes also cover update and delete +operations for that resource; `superset:sqllab:write` covers SQL execution. + +Keys created without scopes retain legacy RBAC-only behavior. The scoped-key +restrictions described here are enforced by the MCP server; regular REST API +routes continue to apply their existing Superset RBAC checks. + #### Use Cases - **CI/CD pipelines** — automated chart/dashboard exports and imports diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json index ac0264764ce8..f3f29908b554 100644 --- a/docs/static/resources/openapi.json +++ b/docs/static/resources/openapi.json @@ -3407,22 +3407,26 @@ "nullable": true, "type": "string" }, + "description": { + "nullable": true, + "type": "string" + }, + "editors": { + "items": { + "$ref": "#/components/schemas/DashboardRestApi.get_list.Subject" + }, + "type": "array" + }, "id": { "type": "integer" }, "is_managed_externally": { "type": "boolean" }, - "owners": { - "$ref": "#/components/schemas/DashboardRestApi.get_list.User2" - }, "published": { "nullable": true, "type": "boolean" }, - "roles": { - "$ref": "#/components/schemas/DashboardRestApi.get_list.Role" - }, "slug": { "maxLength": 255, "nullable": true, @@ -3432,10 +3436,10 @@ "readOnly": true }, "tags": { - "$ref": "#/components/schemas/DashboardRestApi.get_list.Tag" - }, - "thumbnail_url": { - "readOnly": true + "items": { + "$ref": "#/components/schemas/DashboardRestApi.get_list.Tag" + }, + "type": "array" }, "url": { "readOnly": true @@ -3444,57 +3448,65 @@ "format": "uuid", "nullable": true, "type": "string" + }, + "viewers": { + "items": { + "$ref": "#/components/schemas/DashboardRestApi.get_list.Subject1" + }, + "type": "array" } }, "type": "object" }, - "DashboardRestApi.get_list.Role": { + "DashboardRestApi.get_list.Subject": { "properties": { "id": { "type": "integer" }, - "name": { - "maxLength": 64, + "label": { + "maxLength": 255, "type": "string" + }, + "type": { + "type": "integer" } }, - "required": ["name"], + "required": ["label", "type"], "type": "object" }, - "DashboardRestApi.get_list.Tag": { + "DashboardRestApi.get_list.Subject1": { "properties": { "id": { "type": "integer" }, - "name": { - "maxLength": 250, - "nullable": true, + "label": { + "maxLength": 255, "type": "string" }, "type": { - "enum": [1, 2, 3, 4] + "type": "integer" } }, + "required": ["label", "type"], "type": "object" }, - "DashboardRestApi.get_list.User": { + "DashboardRestApi.get_list.Tag": { "properties": { - "first_name": { - "maxLength": 64, - "type": "string" - }, "id": { "type": "integer" }, - "last_name": { - "maxLength": 64, + "name": { + "maxLength": 250, + "nullable": true, "type": "string" + }, + "type": { + "enum": [1, 2, 3, 4] } }, - "required": ["first_name", "last_name"], "type": "object" }, - "DashboardRestApi.get_list.User1": { + "DashboardRestApi.get_list.User": { "properties": { "first_name": { "maxLength": 64, @@ -3511,7 +3523,7 @@ "required": ["first_name", "last_name"], "type": "object" }, - "DashboardRestApi.get_list.User2": { + "DashboardRestApi.get_list.User1": { "properties": { "first_name": { "maxLength": 64, @@ -16506,7 +16518,7 @@ }, "result": { "items": { - "type": "object" + "$ref": "#/components/schemas/DashboardRestApi.get_list" }, "type": "array" } diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 04056e0216e2..2638131cc5fd 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -81,7 +81,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", @@ -254,7 +254,7 @@ "mini-css-extract-plugin": "^2.10.2", "minimizer-webpack-plugin": "^5.6.1", "open-cli": "^9.0.0", - "oxfmt": "^0.62.0", + "oxfmt": "^0.63.0", "oxlint": "^1.78.0", "po2json": "^0.4.5", "postcss-styled-syntax": "^0.7.2", @@ -8441,9 +8441,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" ], @@ -8458,9 +8458,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" ], @@ -8475,9 +8475,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" ], @@ -8492,9 +8492,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" ], @@ -8509,9 +8509,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" ], @@ -8526,9 +8526,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" ], @@ -8543,9 +8543,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" ], @@ -8560,9 +8560,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" ], @@ -8580,9 +8580,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" ], @@ -8600,9 +8600,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" ], @@ -8620,9 +8620,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" ], @@ -8640,9 +8640,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" ], @@ -8660,9 +8660,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" ], @@ -8680,9 +8680,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" ], @@ -8700,9 +8700,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" ], @@ -8720,9 +8720,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" ], @@ -8737,9 +8737,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" ], @@ -8754,9 +8754,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" ], @@ -8771,9 +8771,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" ], @@ -9479,13 +9479,13 @@ } }, "node_modules/@rc-component/cascader": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.17.0.tgz", - "integrity": "sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.22.0.tgz", + "integrity": "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==", "license": "MIT", "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" }, @@ -9627,14 +9627,14 @@ } }, "node_modules/@rc-component/image": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.9.0.tgz", - "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.10.0.tgz", + "integrity": "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==", "license": "MIT", "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" }, "peerDependencies": { @@ -9672,16 +9672,34 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@rc-component/listy": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rc-component/listy/-/listy-1.2.3.tgz", + "integrity": "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==", + "license": "MIT", + "dependencies": { + "@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" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@rc-component/mentions": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.10.0.tgz", - "integrity": "sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.11.0.tgz", + "integrity": "sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==", "license": "MIT", "dependencies": { - "@rc-component/input": "~1.3.0", + "@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" }, "peerDependencies": { @@ -9928,9 +9946,9 @@ } }, "node_modules/@rc-component/select": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.8.2.tgz", - "integrity": "sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.0.tgz", + "integrity": "sha512-u/3yuF2kEXvTJXPy3P7qkVBkGGZcQo+m1uTuQqJa6qCnwdmDoEn1Rs3zTTi6y/RsLGiQGM0drPN5c/RfflCnow==", "license": "MIT", "dependencies": { "@rc-component/overflow": "^1.0.0", @@ -9996,9 +10014,9 @@ } }, "node_modules/@rc-component/table": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.4.tgz", - "integrity": "sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.11.1.tgz", + "integrity": "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==", "license": "MIT", "dependencies": { "@rc-component/context": "^2.0.1", @@ -10016,9 +10034,9 @@ } }, "node_modules/@rc-component/tabs": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.11.0.tgz", - "integrity": "sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.12.0.tgz", + "integrity": "sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==", "license": "MIT", "dependencies": { "@rc-component/dropdown": "~1.0.0", @@ -10037,13 +10055,13 @@ } }, "node_modules/@rc-component/tooltip": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", - "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.5.0.tgz", + "integrity": "sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==", "license": "MIT", "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" }, "peerDependencies": { @@ -10071,9 +10089,9 @@ } }, "node_modules/@rc-component/tree": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.2.tgz", - "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.4.0.tgz", + "integrity": "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==", "license": "MIT", "dependencies": { "@rc-component/motion": "^1.0.0", @@ -10090,13 +10108,13 @@ } }, "node_modules/@rc-component/tree-select": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.11.0.tgz", - "integrity": "sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.16.1.tgz", + "integrity": "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==", "license": "MIT", "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" }, @@ -10160,12 +10178,12 @@ "license": "MIT" }, "node_modules/@rc-component/virtual-list": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz", - "integrity": "sha512-iavRm1Jo4GDbASQwdGa7jFyk93RvSOo9xHyBT4QL1pgFJj/Fdf1G+3RErH7/7BmAMvx2AkF62mjGYxDbXsK9TQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz", + "integrity": "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.20.0", + "@babel/runtime": "^8.0.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" @@ -10178,6 +10196,12 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", @@ -14886,9 +14910,9 @@ } }, "node_modules/antd": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/antd/-/antd-6.5.4.tgz", - "integrity": "sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.0.tgz", + "integrity": "sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==", "license": "MIT", "dependencies": { "@ant-design/colors": "^8.0.1", @@ -14898,7 +14922,7 @@ "@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", @@ -14906,10 +14930,11 @@ "@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", @@ -14921,16 +14946,16 @@ "@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", @@ -27160,9 +27185,9 @@ } }, "node_modules/kdbush": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", - "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", "license": "ISC" }, "node_modules/keyv": { @@ -31845,9 +31870,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": { @@ -31863,25 +31888,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", @@ -43048,15 +43073,6 @@ "node": ">=12" } }, - "packages/superset-ui-core/node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "packages/superset-ui-core/node_modules/react-ace": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz", @@ -43643,7 +43659,7 @@ "mapbox-gl": "^3.28.1", "maplibre-gl": "^5.24.0", "react-map-gl": "^8.1.2", - "supercluster": "^8.0.1" + "supercluster": "^9.0.0" }, "peerDependencies": { "@apache-superset/core": "*", @@ -43653,6 +43669,15 @@ "react-dom": "^18.3.0" } }, + "plugins/plugin-chart-point-cluster-map/node_modules/supercluster": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-9.0.0.tgz", + "integrity": "sha512-SaU8dQaxagTXr8+a1f67Pxg5CiRcZsq+LPQsZoV2r+FD8EF3Wpg5v7Zl8STPhml3oWHF1CQ8YaJGf8Uo8zS5qg==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, "plugins/plugin-chart-table": { "name": "@superset-ui/plugin-chart-table", "version": "0.20.3", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 85782229f5b1..737bad5a7022 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -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", @@ -331,7 +331,7 @@ "mini-css-extract-plugin": "^2.10.2", "minimizer-webpack-plugin": "^5.6.1", "open-cli": "^9.0.0", - "oxfmt": "^0.62.0", + "oxfmt": "^0.63.0", "oxlint": "^1.78.0", "po2json": "^0.4.5", "postcss-styled-syntax": "^0.7.2", diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx index 380fefecb2bd..b7e61498e676 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx @@ -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', { diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx index 5bd73e8d002f..8e71afb4b794 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx @@ -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 = () => diff --git a/superset-frontend/packages/superset-ui-core/src/utils/tooltip.ts b/superset-frontend/packages/superset-ui-core/src/utils/tooltip.ts index 15e5b4be4de5..2b1312a680a2 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/tooltip.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/tooltip.ts @@ -19,19 +19,71 @@ import { t } from '@apache-superset/core/translation'; import { sanitizeHtml } from './html'; +export type TooltipTruncationMode = 'off' | 'end' | 'start' | 'middle'; + +export const TRUNCATION_MAX_CHARS = 40; + const TRUNCATION_STYLE = ` max-width: 300px; overflow: hidden; text-overflow: ellipsis; `; +const NOWRAP_STYLE = ` + white-space: nowrap; +`; + +/** + * Shortens plain text so a tooltip label stays readable, placing the ellipsis + * where the caller asked for it. + * + * Only 'start' and 'middle' slice. 'end' is handled by CSS in tooltipHtml, and + * 'off' means no truncation at all, so both return the input untouched. + * + * The input must be plain text. Callers are responsible for truncating before + * any markup (such as the ECharts series marker) is prepended, and before + * sanitization — slicing a string that already contains markup would cut into + * a tag. + */ +export function truncateLabel( + text: string, + mode: TooltipTruncationMode = 'end', +): string { + if ( + (mode !== 'start' && mode !== 'middle') || + text.length <= TRUNCATION_MAX_CHARS + ) { + return text; + } + const budget = TRUNCATION_MAX_CHARS - 1; + if (mode === 'start') { + return `…${text.slice(-budget)}`; + } + const head = Math.ceil(budget / 2); + const tail = Math.floor(budget / 2); + return `${text.slice(0, head)}…${text.slice(-tail)}`; +} + +function getTruncationStyle(mode: TooltipTruncationMode): string { + if (mode === 'end') { + return TRUNCATION_STYLE; + } + if (mode === 'off') { + return ''; + } + // 'start' and 'middle' are already sliced upstream; keep them on one line. + return NOWRAP_STYLE; +} + export function tooltipHtml( data: string[][], title?: string, focusedRow?: number, + truncation: TooltipTruncationMode = 'end', ) { + const truncationStyle = getTruncationStyle(truncation); const titleRow = title - ? `${title}` + ? `${title}` : ''; return sanitizeHtml(`
@@ -46,7 +98,7 @@ export function tooltipHtml( const cellStyle = ` text-align: ${j > 0 ? 'right' : 'left'}; padding-left: ${j === 0 ? 0 : 16}px; - ${TRUNCATION_STYLE} + ${truncationStyle} `; return `${cell}`; }); diff --git a/superset-frontend/packages/superset-ui-core/test/utils/tooltip.test.ts b/superset-frontend/packages/superset-ui-core/test/utils/tooltip.test.ts index 55ea272167a0..5da38cb10b8e 100644 --- a/superset-frontend/packages/superset-ui-core/test/utils/tooltip.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/utils/tooltip.test.ts @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { sanitizeHtml, tooltipHtml } from '@superset-ui/core'; +import { + sanitizeHtml, + tooltipHtml, + truncateLabel, + TRUNCATION_MAX_CHARS, +} from '@superset-ui/core'; const TITLE_STYLE = 'style="font-weight: 700;max-width:300px;overflow:hidden;text-overflow:ellipsis;"'; @@ -182,3 +187,88 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo expect(html).toContain('padding-left:16px'); expect(html).toContain('max-width:300px'); }); + +describe('truncateLabel', () => { + const long = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars + + test('returns text unchanged for off and end', () => { + expect(truncateLabel(long, 'off')).toBe(long); + expect(truncateLabel(long, 'end')).toBe(long); + }); + + test('defaults to end, which does not slice', () => { + expect(truncateLabel(long)).toBe(long); + }); + + test('truncates the start, keeping the distinguishing suffix', () => { + expect(truncateLabel(long, 'start')).toBe( + '…-us-east-1-service-checkout-latency-p99', + ); + expect(truncateLabel(long, 'start')).toHaveLength(TRUNCATION_MAX_CHARS); + }); + + test('truncates the middle, keeping both ends', () => { + expect(truncateLabel(long, 'middle')).toBe( + 'prod-us-east-1-servi…heckout-latency-p99', + ); + expect(truncateLabel(long, 'middle')).toHaveLength(TRUNCATION_MAX_CHARS); + }); + + test('leaves text at or under the limit untouched', () => { + const atLimit = 'x'.repeat(TRUNCATION_MAX_CHARS); + expect(truncateLabel(atLimit, 'start')).toBe(atLimit); + expect(truncateLabel(atLimit, 'middle')).toBe(atLimit); + expect(truncateLabel('short', 'start')).toBe('short'); + expect(truncateLabel('', 'middle')).toBe(''); + }); + + test('truncates text one character over the limit', () => { + const overLimit = 'x'.repeat(TRUNCATION_MAX_CHARS + 1); + expect(truncateLabel(overLimit, 'start')).toBe( + `…${'x'.repeat(TRUNCATION_MAX_CHARS - 1)}`, + ); + }); +}); + +describe('tooltipHtml truncation modes', () => { + const rows = [['label', 'value']]; + + // sanitizeHtml normalizes spacing inside style attributes, and it does so + // differently across versions, so compare with whitespace stripped. + const styles = ( + title: string | undefined, + truncation?: 'off' | 'end' | 'start' | 'middle', + ) => removeWhitespaces(tooltipHtml(rows, title, undefined, truncation)); + + test('emits the 300px cap for end and for the default', () => { + expect(styles('Title', 'end')).toContain('max-width:300px'); + expect(tooltipHtml(rows, 'Title')).toBe( + tooltipHtml(rows, 'Title', undefined, 'end'), + ); + }); + + test('emits no truncation style for off', () => { + const html = styles('Title', 'off'); + expect(html).not.toContain('max-width'); + expect(html).not.toContain('text-overflow'); + expect(html).not.toContain('white-space'); + }); + + test.each(['start', 'middle'] as const)( + 'emits nowrap instead of a cap for %s', + mode => { + const html = styles('Title', mode); + expect(html).toContain('white-space:nowrap'); + expect(html).not.toContain('max-width'); + }, + ); + + test('never slices cell text itself, whatever the mode', () => { + const longCell = 'y'.repeat(TRUNCATION_MAX_CHARS + 20); + (['off', 'end', 'start', 'middle'] as const).forEach(mode => { + expect(tooltipHtml([[longCell]], undefined, undefined, mode)).toContain( + longCell, + ); + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 019e2300ef59..060fedd29406 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -40,6 +40,7 @@ import { TimeseriesChartDataResponseResult, TimeseriesDataRecord, tooltipHtml, + truncateLabel, ValueFormatter, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; @@ -207,6 +208,7 @@ export default function transformProps( zoomable, richTooltip, tooltipSortByMetric, + tooltipTruncation, xAxisBounds, xAxisLabelRotation, xAxisLabelInterval, @@ -907,13 +909,19 @@ export default function transformProps( formatter: primarySeries.has(key) ? tooltipFormatter : tooltipFormatterSecondary, + truncation: tooltipTruncation, }); rows.push(row); if (key === focusedSeries) { focusedRow = rows.length - 1; } }); - return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow); + return tooltipHtml( + rows, + truncateLabel(tooltipFormatter(xValue), tooltipTruncation), + focusedRow, + tooltipTruncation, + ); }, }, legend: { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts index 3000c995892f..5c1cfe379332 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts @@ -24,6 +24,7 @@ import { ContributionType, TimeFormatter, AxisType, + TooltipTruncationMode, } from '@superset-ui/core'; import { BaseChartProps, @@ -59,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & { timeGrainSqla?: TimeGranularity; forceMaxInterval?: boolean; tooltipTimeFormat?: string; + tooltipTruncation?: TooltipTruncationMode; zoomable: boolean; richTooltip: boolean; showQueryIdentifiers?: boolean; @@ -108,6 +110,7 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = { yAxisFormatSecondary: TIMESERIES_DEFAULTS.yAxisFormat, yAxisTitleSecondary: DEFAULT_TITLE_FORM_DATA.yAxisTitle, tooltipTimeFormat: TIMESERIES_DEFAULTS.tooltipTimeFormat, + tooltipTruncation: TIMESERIES_DEFAULTS.tooltipTruncation, xAxisBounds: TIMESERIES_DEFAULTS.xAxisBounds, xAxisForceCategorical: TIMESERIES_DEFAULTS.xAxisForceCategorical, xAxisTimeFormat: TIMESERIES_DEFAULTS.xAxisTimeFormat, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts index 71430e870c9c..5bb7ca1a33e8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts @@ -73,6 +73,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = { seriesType: EchartsTimeseriesSeriesType.Line, stack: false, tooltipTimeFormat: 'smart_date', + tooltipTruncation: 'end', xAxisTimeFormat: 'smart_date', xAxisNumberFormat: 'SMART_NUMBER', truncateXAxis: true, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index ca7d2f4fe91d..43e9a2f431bb 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -30,6 +30,7 @@ import { DTTM_ALIAS, ensureIsArray, tooltipHtml, + truncateLabel, getCustomFormatter, getMetricLabel, getNumberFormatter, @@ -303,6 +304,7 @@ export default function transformProps( tooltipSortByMetric, showTooltipTotal, showTooltipPercentage, + tooltipTruncation, truncateXAxis, truncateYAxis, xAxis: xAxisOrig, @@ -1449,6 +1451,7 @@ export default function transformProps( seriesName: key, formatter, marker, + truncation: tooltipTruncation, }); const annotationRow = annotationLayers.some( @@ -1482,7 +1485,12 @@ export default function transformProps( } rows.push(totalRow); } - return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow); + return tooltipHtml( + rows, + truncateLabel(tooltipFormatter(xValue), tooltipTruncation), + focusedRow, + tooltipTruncation, + ); }, }, legend: { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts index befc23839684..6f06fb847155 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -25,6 +25,7 @@ import { QueryFormMetric, TimeFormatter, TimeGranularity, + TooltipTruncationMode, } from '@superset-ui/core'; import { BaseChartProps, @@ -82,6 +83,7 @@ export type EchartsTimeseriesFormData = QueryFormData & { tooltipTimeFormat?: string; showTooltipTotal?: boolean; showTooltipPercentage?: boolean; + tooltipTruncation?: TooltipTruncationMode; truncateXAxis: boolean; truncateYAxis: boolean; yAxisFormat?: string; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index c0aa4f4f3089..2597b574ae84 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -315,6 +315,27 @@ const tooltipPercentageControl: ControlSetItem = { }, }; +const tooltipTruncationControl: ControlSetItem = { + name: 'tooltipTruncation', + config: { + type: 'SelectControl', + freeForm: false, + label: t('Truncate labels'), + renderTrigger: true, + default: 'end', + clearable: false, + choices: [ + ['off', t('Off')], + ['end', t('End')], + ['start', t('Start')], + ['middle', t('Middle')], + ], + description: t( + 'Where to place the ellipsis when a tooltip label is too long. Choose Off to always show the full label, or Start when labels share a common prefix.', + ), + }, +}; + export const richTooltipSection: ControlSetRow[] = [ [{t('Tooltip')}], [richTooltipControl], @@ -322,6 +343,7 @@ export const richTooltipSection: ControlSetRow[] = [ [tooltipPercentageControl], [tooltipSortByMetricControl], [tooltipTimeFormatControl], + [tooltipTruncationControl], ]; const sortSeriesType: ControlSetItem = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts index 18ee19b0d58e..c5d087aa7104 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { DataRecord, DTTM_ALIAS, ValueFormatter } from '@superset-ui/core'; +import { + DataRecord, + DTTM_ALIAS, + truncateLabel, + TooltipTruncationMode, + ValueFormatter, +} from '@superset-ui/core'; import type { OptionName, SeriesOption } from 'echarts/types/src/util/types'; import type { TooltipMarker } from 'echarts/types/src/util/format'; import { @@ -91,12 +97,16 @@ export const formatForecastTooltipSeries = ({ forecastUpper, marker, formatter, + truncation = 'end', }: ForecastValue & { seriesName: string; marker: TooltipMarker; formatter: ValueFormatter; + truncation?: TooltipTruncationMode; }): string[] => { - const name = `${marker}${sanitizeHtml(seriesName)}`; + // Truncate before sanitizing and before the marker is prepended: slicing a + // string that already contains markup would cut into the marker's tag. + const name = `${marker}${sanitizeHtml(truncateLabel(seriesName, truncation))}`; let value = typeof observation === 'number' ? formatter(observation) : ''; // Use finite-number checks rather than truthiness so that legitimate // zero values (e.g. a forecast that crosses zero, or a confidence bound of diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts index a670286470c5..d2f8b584ef02 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts @@ -27,6 +27,7 @@ import { VizType, ChartDataResponseResult, TimeGranularity, + TooltipTruncationMode, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { @@ -1295,3 +1296,59 @@ test('y-axis title position: non-Left sets nameLocation to end', () => { expect(yAxis[1].nameGap).toEqual(30); expect(yAxis[1].nameLocation).toEqual('end'); }); +describe('EchartsMixedTimeseries tooltip truncation', () => { + const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99'; + const marker = ''; + + const buildTooltip = (tooltipTruncation?: TooltipTruncationMode) => { + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsMixedTimeseriesFormData, + EchartsMixedTimeseriesProps + >({ + ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS, + defaultQueriesData: queriesData, + formData: { + ...formData, + ...(tooltipTruncation ? { tooltipTruncation } : {}), + }, + queriesData, + }); + const { echartOptions } = transformProps(chartProps); + const { formatter } = echartOptions.tooltip as { + formatter: (params: unknown) => string; + }; + // richTooltip is false in this fixture, so the trigger is 'item' and the + // formatter receives a single param object rather than an array. + return formatter({ + seriesId: longSeriesName, + seriesName: longSeriesName, + value: [599616000000, 1], + marker, + }); + }; + + test('keeps full text with the CSS cap by default', () => { + const html = buildTooltip(); + expect(html.replace(/\s/g, '')).toContain('max-width:300px'); + expect(html).toContain(longSeriesName); + }); + + test('removes the cap and keeps full text when off', () => { + const html = buildTooltip('off'); + expect(html).not.toContain('max-width'); + expect(html).toContain(longSeriesName); + }); + + test('drops the shared prefix when truncating from the start', () => { + const html = buildTooltip('start'); + expect(html).not.toContain('prod-us-east'); + expect(html).toContain('latency-p99'); + expect(html).toContain('background-color:#1f77b4'); + }); + + test('keeps both ends when truncating the middle', () => { + const html = buildTooltip('middle'); + expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99'); + expect(html).not.toContain(longSeriesName); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index c4169f1ea855..ff44aa7d3b60 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -32,6 +32,7 @@ import { TimeseriesAnnotationLayer, ChartDataResponseResult, TimeGranularity, + TooltipTruncationMode, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { supersetTheme } from '@apache-superset/core/theme'; @@ -2437,3 +2438,94 @@ test('honors the snake_case flag the compare-chart migration stores in params', [BASE_TIMESTAMP + 300000000, 2], ]); }); +describe('EchartsTimeseries tooltip truncation', () => { + const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99'; + const marker = ''; + + const buildTooltip = ( + tooltipTruncation?: TooltipTruncationMode, + xValue: string | number = 599616000000, + ) => { + const chartProps = new ChartProps({ + formData: { + colorScheme: 'bnbColors', + datasource: '3__table', + granularity_sqla: 'ds', + metric: 'sum__num', + groupby: ['foo'], + viz_type: 'my_viz', + ...(tooltipTruncation ? { tooltipTruncation } : {}), + } as SqlaFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { [longSeriesName]: 1, __timestamp: 599616000000 }, + { [longSeriesName]: 3, __timestamp: 599916000000 }, + ], + }, + ], + theme: supersetTheme, + }); + const { echartOptions } = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const { formatter } = echartOptions.tooltip as { + formatter: (params: unknown) => string; + }; + return formatter([ + { + seriesId: longSeriesName, + seriesName: longSeriesName, + value: [xValue, 1], + marker, + }, + ]); + }; + + test('applies the CSS cap and keeps full text by default', () => { + const html = buildTooltip(); + expect(html).toContain(longSeriesName); + // sanitizeHtml normalizes spacing inside style attributes, so compare with + // whitespace stripped rather than hard-coding one version's formatting. + expect(html.replace(/\s/g, '')).toContain('max-width:300px'); + }); + + test('removes the cap and keeps full text when off', () => { + const html = buildTooltip('off'); + expect(html).not.toContain('max-width'); + expect(html).toContain(longSeriesName); + }); + + test('drops the shared prefix when truncating from the start', () => { + const html = buildTooltip('start'); + expect(html).not.toContain('prod-us-east'); + expect(html).toContain('latency-p99'); + expect(html.replace(/\s/g, '')).toContain('white-space:nowrap'); + }); + + test('keeps both ends when truncating the middle', () => { + const html = buildTooltip('middle'); + expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99'); + expect(html).not.toContain(longSeriesName); + }); + + test('preserves the echarts marker in every mode', () => { + (['off', 'end', 'start', 'middle'] as const).forEach(mode => { + expect(buildTooltip(mode)).toContain('background-color:#1f77b4'); + }); + }); + + test('truncates a long non-temporal x-axis title', () => { + const longCategory = 'prod-us-east-1-service-checkout-cohort-2026'; + const html = buildTooltip('start', longCategory); + expect(html).not.toContain(longCategory); + expect(html).toContain('cohort-2026'); + }); + + test('leaves a long title alone in the default mode', () => { + const longCategory = 'prod-us-east-1-service-checkout-cohort-2026'; + expect(buildTooltip(undefined, longCategory)).toContain(longCategory); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/forecast.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/forecast.test.ts index 40a4fed4e76f..b2086c543067 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/forecast.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/forecast.test.ts @@ -16,7 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { getNumberFormatter, NumberFormats } from '@superset-ui/core'; +import { + getNumberFormatter, + NumberFormats, + TRUNCATION_MAX_CHARS, +} from '@superset-ui/core'; import { SeriesOption } from 'echarts'; import { extractForecastSeriesContext, @@ -411,3 +415,52 @@ test('formatForecastTooltipSeries should skip non-finite forecast values', () => }), ).toEqual(['qwerty', '10']); }); + +describe('formatForecastTooltipSeries truncation', () => { + const marker = + ''; + const longName = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars + const intFormatter = getNumberFormatter(NumberFormats.INTEGER); + + const format = (truncation?: 'off' | 'end' | 'start' | 'middle') => + formatForecastTooltipSeries({ + seriesName: longName, + observation: 1, + marker, + formatter: intFormatter, + ...(truncation ? { truncation } : {}), + })[0]; + + test('leaves the name intact by default and for off/end', () => { + expect(format()).toContain(longName); + expect(format('off')).toContain(longName); + expect(format('end')).toContain(longName); + }); + + test('slices the start of the name without harming the marker', () => { + const cell = format('start'); + expect(cell).toContain(marker); + expect(cell).toContain('…-us-east-1-service-checkout-latency-p99'); + expect(cell).not.toContain('prod-us-east'); + }); + + test('slices the middle of the name without harming the marker', () => { + const cell = format('middle'); + expect(cell).toContain(marker); + expect(cell).toContain('prod-us-east-1-servi…heckout-latency-p99'); + }); + + test('measures the budget against the name, not the marker markup', () => { + // The marker alone is far longer than the budget. If truncation were + // applied to the concatenated cell, a short name would be mangled. + expect(marker.length).toBeGreaterThan(TRUNCATION_MAX_CHARS); + const [cell] = formatForecastTooltipSeries({ + seriesName: 'cpu', + observation: 1, + marker, + formatter: intFormatter, + truncation: 'start', + }); + expect(cell).toBe(`${marker}cpu`); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json b/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json index 9b39ea31de55..225026f92090 100644 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json +++ b/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json @@ -33,7 +33,7 @@ "mapbox-gl": "^3.28.1", "maplibre-gl": "^5.24.0", "react-map-gl": "^8.1.2", - "supercluster": "^8.0.1" + "supercluster": "^9.0.0" }, "peerDependencies": { "@apache-superset/core": "*", diff --git a/superset-frontend/spec/helpers/testing-library.tsx b/superset-frontend/spec/helpers/testing-library.tsx index 1843036e29ea..1fbbc80f8a70 100644 --- a/superset-frontend/spec/helpers/testing-library.tsx +++ b/superset-frontend/spec/helpers/testing-library.tsx @@ -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); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx index ba128d56dc06..3a155f7fece5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx @@ -16,8 +16,20 @@ * specific language governing permissions and limitations * under the License. */ +import { + ChartCustomizationType, + type ChartCustomization, +} from '@superset-ui/core'; import { LabeledValue } from '@superset-ui/core/components'; -import { createLabelSortComparator } from './GroupByFilterCard'; +import { render, screen } from 'spec/helpers/testing-library'; +import GroupByFilterCard, { + createLabelSortComparator, +} from './GroupByFilterCard'; + +jest.mock('src/utils/cachedSupersetGet', () => ({ + // Never resolves, pinning the card in its column-loading state. + cachedSupersetGet: jest.fn(() => new Promise(() => {})), +})); const apple: LabeledValue = { value: 'a', label: 'Apple' }; const banana: LabeledValue = { value: 'b', label: 'Banana' }; @@ -39,3 +51,27 @@ test('preserves source order when sortAscending is unset', () => { expect(compare(apple, banana)).toBe(0); expect(compare(banana, apple)).toBe(0); }); + +const groupByCustomization: ChartCustomization = { + id: 'groupby-1', + name: 'Group By', + filterType: 'filter_groupby', + type: ChartCustomizationType.ChartCustomization, + targets: [{ datasetId: 1 }], + scope: { rootPath: [], excluded: [] }, + controlValues: {}, + defaultDataMask: {}, +}; + +test('renders the column-loading spinner small and muted', async () => { + render(, { + useRedux: true, + initialState: { + dataMask: {}, + nativeFilters: { filters: {} }, + }, + }); + const spinner = await screen.findByTestId('loading-indicator'); + expect(spinner).toHaveClass('inline'); + expect(spinner).toHaveStyle({ opacity: 0.25, width: '40px' }); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx index d214be3d7861..7363dfcab68d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx @@ -645,7 +645,7 @@ const GroupByFilterCard: FC = ({ {loading && (
- +
)}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx index 4229e615ce43..b1458392414f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx @@ -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'); } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.test.ts index 5e222594867e..e0b4bbda1311 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.test.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.test.ts @@ -72,3 +72,13 @@ test('omits datasourceType when undefined', () => { }); expect(target).not.toHaveProperty('datasourceType'); }); + +test('omits datasourceType when there is no dataset', () => { + // The modal stamps a hidden ``datasourceType`` field on every filter form, + // including dataset-less types. Without a dataset there is nothing for it to + // describe, and emitting it would diverge from the ``{}`` target the import + // and seed paths write. + expect( + buildNativeFilterTarget({ datasourceType: DatasourceType.Table }), + ).toEqual({}); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.ts index 7d493b29d5b2..2b59a3085ab4 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/buildTarget.ts @@ -33,9 +33,9 @@ export interface TargetFormInputs { * Build the ``NativeFilterTarget`` carried by a native filter or chart * customization from its form inputs. * - * Consolidates what used to live in three places — ``filterTransformer``, - * ``customizationTransformer``, and ``createHandleSave`` — so changes to the - * target shape only need to happen here. + * Consolidates what used to live in ``filterTransformer`` and + * ``customizationTransformer`` so changes to the target shape only need to + * happen here. */ export function buildNativeFilterTarget( formInputs: TargetFormInputs, @@ -49,7 +49,11 @@ export function buildNativeFilterTarget( : formInputs.dataset; } - if (formInputs.datasourceType) { + // ``datasourceType`` describes the selected dataset, so it only belongs on a + // target that has one. Emitting it for a dataset-less filter (e.g. + // ``filter_time``) would make a UI save serialize a target the import and + // seed paths write as ``{}``. + if (formInputs.dataset != null && formInputs.datasourceType) { target.datasourceType = formInputs.datasourceType; } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.test.ts new file mode 100644 index 000000000000..33f9f4f61abd --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.test.ts @@ -0,0 +1,102 @@ +/** + * 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 { ChartCustomization, ChartCustomizationType } from '@superset-ui/core'; +import { ChartCustomizationsFormItem } from '../types'; +import { transformCustomizationForSave } from './customizationTransformer'; + +const baseFormItem = { + type: ChartCustomizationType.ChartCustomization, + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + controlValues: {}, + requiredFirst: {}, + defaultValue: null, + defaultDataMask: { filterState: {}, extraFormData: {} }, + sortMetric: null, + description: '', + // form-only field that must never leak into the saved customization + defaultValueQueriesData: null, +} as unknown as ChartCustomizationsFormItem; + +test('serializes a dataset-less customization into a full ChartCustomization', () => { + // Customization plugins declaring ``datasourceCount: 0`` render no dataset + // control, so their form item carries neither ``dataset`` nor ``targets``. + const formItem = { + ...baseFormItem, + name: 'Layer visibility', + filterType: 'customization_deckgl_layer_visibility', + } as unknown as ChartCustomizationsFormItem; + + const result = transformCustomizationForSave( + 'CHART_CUSTOMIZATION-abc', + formItem, + ) as ChartCustomization; + + expect(result.targets).toEqual([{}]); + expect(result.defaultDataMask).toBeDefined(); + expect(result.removed).toBe(false); + expect(result).not.toHaveProperty('defaultValueQueriesData'); +}); + +test('serializes a dataset-backed customization into a full ChartCustomization', () => { + const formItem = { + ...baseFormItem, + name: 'Group by', + filterType: 'customization_dynamic_group_by', + dataset: { value: 42, label: 'sales' }, + column: 'region', + } as unknown as ChartCustomizationsFormItem; + + const result = transformCustomizationForSave( + 'CHART_CUSTOMIZATION-def', + formItem, + ) as ChartCustomization; + + expect(result.targets).toEqual([ + { datasetId: 42, column: { name: 'region' } }, + ]); + expect(result).not.toHaveProperty('defaultValueQueriesData'); +}); + +test('passes an already-saved ChartCustomization through untouched', () => { + const saved: ChartCustomization = { + id: 'CHART_CUSTOMIZATION-ghi', + name: 'Group by', + filterType: 'customization_dynamic_group_by', + type: ChartCustomizationType.ChartCustomization, + targets: [{ datasetId: 42, column: { name: 'region' } }], + defaultDataMask: { filterState: {}, extraFormData: {} }, + controlValues: {}, + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + description: ' needs trim ', + chartsInScope: [1, 2], + tabsInScope: ['TAB-1'], + }; + + const result = transformCustomizationForSave( + 'CHART_CUSTOMIZATION-ghi', + saved, + ) as ChartCustomization; + + expect(result.targets).toEqual([ + { datasetId: 42, column: { name: 'region' } }, + ]); + expect(result.chartsInScope).toEqual([1, 2]); + expect(result.tabsInScope).toEqual(['TAB-1']); + expect(result.description).toBe('needs trim'); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts index 1bee2f2d107f..737faa40f212 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts @@ -69,7 +69,10 @@ function isDividerType( function isFormInput( formInputs: ChartCustomizationFormOrSaved, ): formInputs is ChartCustomizationsFormItem { - return 'dataset' in formInputs && typeof formInputs.dataset === 'object'; + // Mirrors `filterTransformer`: a saved customization always carries a + // serialized `targets` array, and dataset-less types (e.g. the deck.gl layer + // visibility customization) have no `dataset` to discriminate on. + return !('targets' in formInputs); } function transformCustomizationDivider( diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.test.ts new file mode 100644 index 000000000000..d4811c018937 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.test.ts @@ -0,0 +1,156 @@ +/** + * 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 { DatasourceType, Filter, NativeFilterType } from '@superset-ui/core'; +import { NativeFiltersFormItem } from '../types'; +import { transformFilterForSave } from './filterTransformer'; + +const baseFormItem = { + type: NativeFilterType.NativeFilter, + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + controlValues: {}, + requiredFirst: {}, + defaultValue: null, + defaultDataMask: { filterState: {}, extraFormData: {} }, + description: '', + // form-only fields that must never leak into the saved filter + defaultValueQueriesData: null, +} as unknown as NativeFiltersFormItem; + +test('serializes a dataset-less filter (filter_time) into a full Filter', () => { + // A ``filter_time`` filter has no dataset/column controls, so its form item + // carries neither a ``dataset`` nor a ``targets`` key. It must still be + // transformed like any other native filter rather than persisted verbatim. + const formItem: NativeFiltersFormItem = { + ...baseFormItem, + name: 'Time Range', + filterType: 'filter_time', + dependencies: ['NATIVE_FILTER-parent'], + // the modal stamps this on every filter form, dataset or not + datasourceType: DatasourceType.Table, + }; + + const result = transformFilterForSave( + 'NATIVE_FILTER-abc', + formItem, + ) as Filter; + + // Keys the bug used to strip are present and well-formed. The target matches + // the ``{}`` the import and seed paths write, so one logical filter has one + // serialization regardless of provenance. + expect(result.targets).toEqual([{}]); + expect(result.defaultDataMask).toBeDefined(); + expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']); + + // Form-only keys must not leak into the persisted config. + expect(result).not.toHaveProperty('defaultValueQueriesData'); + expect(result).not.toHaveProperty('dependencies'); + // Empty requiredFirst collapses to undefined instead of the raw form object. + expect(result.requiredFirst).toBeUndefined(); + + // A dataset-less filter has no sort metric control, so the persisted document + // must not gain a ``sortMetric`` key it never had. Asserted on the serialized + // form because ``undefined`` values survive in the object but not in JSON. + expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty('sortMetric'); + + expect(result.name).toBe('Time Range'); + expect(result.filterType).toBe('filter_time'); +}); + +test('serializes a dataset-backed filter (filter_select) into a full Filter', () => { + const formItem: NativeFiltersFormItem = { + ...baseFormItem, + name: 'Region', + filterType: 'filter_select', + dataset: { value: 42, label: 'sales' }, + column: 'region', + dependencies: [], + }; + + const result = transformFilterForSave( + 'NATIVE_FILTER-def', + formItem, + ) as Filter; + + expect(result.targets).toEqual([ + { datasetId: 42, column: { name: 'region' } }, + ]); + expect(result.defaultDataMask).toBeDefined(); + expect(result.cascadeParentIds).toEqual([]); + expect(result).not.toHaveProperty('defaultValueQueriesData'); +}); + +test('passes an already-saved Filter through untouched (aside from trimming)', () => { + // Values coming from the stored filter config map (e.g. cascade-parent + // cleanup) already carry a ``targets`` array and must be preserved as-is. + const savedFilter: Filter = { + id: 'NATIVE_FILTER-ghi', + name: 'Time Range', + filterType: 'filter_time', + type: NativeFilterType.NativeFilter, + targets: [{}], + defaultDataMask: { filterState: {}, extraFormData: {} }, + cascadeParentIds: ['NATIVE_FILTER-parent'], + controlValues: {}, + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + description: ' needs trim ', + chartsInScope: [1, 2], + tabsInScope: ['TAB-1'], + }; + + const result = transformFilterForSave( + 'NATIVE_FILTER-ghi', + savedFilter, + ) as Filter; + + expect(result.targets).toEqual([{}]); + expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']); + expect(result.chartsInScope).toEqual([1, 2]); + expect(result.tabsInScope).toEqual(['TAB-1']); + expect(result.description).toBe('needs trim'); +}); + +test('rebuilds a saved filter whose targets were already stripped', () => { + // Dashboards affected by this bug hold ``filter_time`` entries with no + // ``targets``. They no longer match the saved-filter branch, so they take the + // form-item path and are repaired on the next save. ``cascadeParentIds`` is + // read from the form's ``dependencies``, which such an entry does not carry — + // the same write that stripped ``targets`` stripped ``cascadeParentIds`` too. + const strippedFilter = { + id: 'NATIVE_FILTER-jkl', + name: 'Time Range', + filterType: 'filter_time', + type: NativeFilterType.NativeFilter, + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + controlValues: { timeShift: false }, + description: '', + requiredFirst: { 'NATIVE_FILTER-jkl': true }, + defaultValueQueriesData: null, + } as unknown as NativeFiltersFormItem; + + const result = transformFilterForSave( + 'NATIVE_FILTER-jkl', + strippedFilter, + ) as Filter; + + expect(result.targets).toEqual([{}]); + expect(result.defaultDataMask).toBeDefined(); + expect(result.requiredFirst).toBe(true); + expect(result.cascadeParentIds).toEqual([]); + expect(result).not.toHaveProperty('defaultValueQueriesData'); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts index 3ceabffc152b..b19621db7d45 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts @@ -67,7 +67,10 @@ function isDividerType( function isFormInput( formInputs: NativeFilterFormOrSaved, ): formInputs is NativeFiltersFormItem { - return 'dataset' in formInputs; + // A saved filter always carries a serialized `targets` array; a form item + // never does. Keying this off `dataset` misclassified filter types with no + // dataset control (e.g. `filter_time`) as already saved. + return !('targets' in formInputs); } function transformDivider( @@ -115,7 +118,7 @@ function transformFormInput( adhoc_filters: formInputs.adhoc_filters, time_range: formInputs.time_range, granularity_sqla: formInputs.granularity_sqla, - sortMetric: formInputs.sortMetric ?? null, + sortMetric: formInputs.sortMetric, requiredFirst: formInputs.requiredFirst ? Object.values(formInputs.requiredFirst).find(rf => rf) : undefined, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts index e79d45599184..87aa9c8ccbaa 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts @@ -18,21 +18,15 @@ */ import type { FormInstance } from '@superset-ui/core/components'; import { nanoid } from 'nanoid'; -import { getInitialDataMask } from 'src/dataMask/reducer'; import { FilterConfiguration, NativeFilterType, - NativeFilterTarget, - Filter, - Divider, ChartCustomizationType, ChartCustomizationConfiguration, ChartCustomization, ChartCustomizationDivider, } from '@superset-ui/core'; import { logging } from '@apache-superset/core/utils'; -import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; -import { buildNativeFilterTarget } from './transformers/buildTarget'; import { ChartCustomizationsForm, FilterChangesType, @@ -101,70 +95,6 @@ export const validateForm = async ( } }; -export const createHandleSave = - ( - saveForm: Function, - filterChanges: FilterChangesType, - values: NativeFiltersForm, - filterConfigMap: Record, - ) => - async () => { - const transformFilter = (id: string) => { - const formInputs = values.filters?.[id] || filterConfigMap[id]; - if (!formInputs) { - return undefined; - } - if (formInputs.type === NativeFilterType.Divider) { - return { - id, - type: NativeFilterType.Divider, - scope: { - rootPath: [DASHBOARD_ROOT_ID], - excluded: [], - }, - title: formInputs.title, - description: formInputs.description, - }; - } - - const target: Partial = - buildNativeFilterTarget(formInputs); - - return { - id, - adhoc_filters: formInputs.adhoc_filters, - time_range: formInputs.time_range, - controlValues: formInputs.controlValues ?? {}, - granularity_sqla: formInputs.granularity_sqla, - ...(formInputs.time_grains?.length - ? { time_grains: formInputs.time_grains } - : {}), - requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find( - rf => rf, - ), - name: formInputs.name, - filterType: formInputs.filterType, - targets: [target], - defaultDataMask: formInputs.defaultDataMask ?? getInitialDataMask(), - cascadeParentIds: formInputs.dependencies || [], - scope: formInputs.scope, - sortMetric: formInputs.sortMetric, - type: formInputs.type, - description: (formInputs.description || '').trim(), - }; - }; - - const transformedModified = filterChanges.modified - .map(transformFilter) - .filter(Boolean); - - const newFilterChanges = { - ...filterChanges, - modified: transformedModified, - }; - await saveForm(newFilterChanges); - }; - export const createHandleRemoveItem = ( setRemovedFilters: ( diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx index 4c3d0fa13aac..f67b3f3f4063 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx @@ -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(); diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx index cf28e55e6199..3dcd9def31c8 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx @@ -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( diff --git a/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx b/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx index cc2cf7d4ac8e..bce1ba696eba 100644 --- a/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx +++ b/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx @@ -27,8 +27,15 @@ import { Input, Button, Modal, + Select, } from '@superset-ui/core/components'; import { useToasts } from 'src/components/MessageToasts/withToasts'; +import copyTextToClipboard from 'src/utils/copy'; +import { + API_KEY_SCOPE_OPTIONS, + getApiKeyScopesHelpText, + serializeApiKeyScopes, +} from './apiKeyScopes'; interface ApiKeyCreateModalProps { show: boolean; @@ -38,6 +45,7 @@ interface ApiKeyCreateModalProps { interface FormValues { name: string; + scopes?: string[]; } export function ApiKeyCreateModal({ @@ -62,9 +70,13 @@ export function ApiKeyCreateModal({ const handleFormSubmit = async (values: FormValues) => { try { + const scopes = serializeApiKeyScopes(values.scopes); const response = await SupersetClient.post({ endpoint: '/api/v1/security/api_keys/', - jsonPayload: values, + jsonPayload: { + name: values.name, + ...(scopes && { scopes }), + }, }); const key = response.json?.result?.key; if (!key) { @@ -83,7 +95,7 @@ export function ApiKeyCreateModal({ return; } try { - await navigator.clipboard.writeText(createdKey); + await copyTextToClipboard(() => Promise.resolve(createdKey)); setCopied(true); if (copyTimerRef.current) { clearTimeout(copyTimerRef.current); @@ -170,6 +182,24 @@ export function ApiKeyCreateModal({ placeholder={t('e.g., CI/CD Pipeline, Analytics Script')} /> + +