diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 3ee12b4941cb..758b81402e4a 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -76,7 +76,7 @@ jobs: fetch-depth: 1 - name: Run Claude PR Action - uses: anthropics/claude-code-action@094bd24d575e7b30ac1576024817bf1a97c81262 # beta + uses: anthropics/claude-code-action@bee87b3258c251f9279e5371b0cc3660f37f3f77 # beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} timeout_minutes: "60" diff --git a/.github/workflows/ephemeral-env.yml b/.github/workflows/ephemeral-env.yml index b9529096f520..b5bd0736180a 100644 --- a/.github/workflows/ephemeral-env.yml +++ b/.github/workflows/ephemeral-env.yml @@ -199,7 +199,7 @@ jobs: - name: Login to Amazon ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@c962da2960ed15f492addc26fffa274485265950 # v2 + uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2 - name: Load, tag and push image to ECR id: push-image @@ -235,7 +235,7 @@ jobs: - name: Login to Amazon ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@c962da2960ed15f492addc26fffa274485265950 # v2 + uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2 - name: Check target image exists in ECR id: check-image diff --git a/UPDATING.md b/UPDATING.md index bbfd509ea440..c8b78b1779ef 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -308,13 +308,13 @@ Note: Pillow is now a required dependency (previously optional) to support image There's a migration added that can potentially affect a significant number of existing charts. - [32317](https://github.com/apache/superset/pull/32317) The horizontal filter bar feature is now out of testing/beta development and its feature flag `HORIZONTAL_FILTER_BAR` has been removed. - [31590](https://github.com/apache/superset/pull/31590) Marks the begining of intricate work around supporting dynamic Theming, and breaks support for [THEME_OVERRIDES](https://github.com/apache/superset/blob/732de4ac7fae88e29b7f123b6cbb2d7cd411b0e4/superset/config.py#L671) in favor of a new theming system based on AntD V5. Likely this will be in disrepair until settling over the 5.x lifecycle. -- [32432](https://github.com/apache/superset/pull/31260) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed. +- [32432](https://github.com/apache/superset/pull/32432) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed. - [34319](https://github.com/apache/superset/pull/34319) Drill to Detail and Drill By is now supported in Embedded mode, and also with the `DASHBOARD_RBAC` FF. If you don't want to expose these features in Embedded / `DASHBOARD_RBAC`, make sure the roles used for Embedded / `DASHBOARD_RBAC`don't have the required permissions to perform D2D actions. ## 5.0.0 - [31976](https://github.com/apache/superset/pull/31976) Removed the `DISABLE_LEGACY_DATASOURCE_EDITOR` feature flag. The previous value of the feature flag was `True` and now the feature is permanently removed. -- [31959](https://github.com/apache/superset/pull/32000) Removes CSV_UPLOAD_MAX_SIZE config, use your web server to control file upload size. +- [32000](https://github.com/apache/superset/pull/32000) Removes CSV_UPLOAD_MAX_SIZE config, use your web server to control file upload size. - [31959](https://github.com/apache/superset/pull/31959) Removes the following endpoints from data uploads: `/api/v1/database//_upload` and `/api/v1/database/_metadata`, in favour of new one (Details on the PR). And simplifies permissions. - [31844](https://github.com/apache/superset/pull/31844) The `ALERT_REPORTS_EXECUTE_AS` and `THUMBNAILS_EXECUTE_AS` config parameters have been renamed to `ALERT_REPORTS_EXECUTORS` and `THUMBNAILS_EXECUTORS` respectively. A new config flag `CACHE_WARMUP_EXECUTORS` has also been introduced to be able to control which user is used to execute cache warmup tasks. Finally, the config flag `THUMBNAILS_SELENIUM_USER` has been removed. To use a fixed executor for async tasks, use the new `FixedExecutor` class. See the config and docs for more info on setting up different executor profiles. - [31894](https://github.com/apache/superset/pull/31894) Domain sharding is deprecated in favor of HTTP2. The `SUPERSET_WEBSERVER_DOMAINS` configuration will be removed in the next major version (6.0) diff --git a/docs/developer_docs/testing/backend-testing.md b/docs/developer_docs/testing/backend-testing.md index 0af638fac5bc..e8c8d229fcb8 100644 --- a/docs/developer_docs/testing/backend-testing.md +++ b/docs/developer_docs/testing/backend-testing.md @@ -63,6 +63,109 @@ pytest tests/unit_tests/ pytest tests/integration_tests/ ``` +## Testing Alerts & Reports with Celery and MailHog + +The Alerts & Reports feature relies on Celery for task scheduling and execution. To test it locally, you need Redis (message broker), Celery Beat (scheduler), a Celery Worker (executor), and an SMTP server to receive email notifications. + +### Prerequisites + +- Redis running on `localhost:6379` +- [MailHog](https://github.com/mailhog/MailHog) installed (a local SMTP server with a web UI for viewing caught emails) + +### superset_config.py + +Your `CeleryConfig` **must** include `beat_schedule`. When you define a custom `CeleryConfig` class in `superset_config.py`, it replaces the default entirely. If you omit `beat_schedule`, Celery Beat will start but never schedule any report tasks. + +```python +from celery.schedules import crontab +from superset.tasks.types import ExecutorType + +REDIS_HOST = "localhost" +REDIS_PORT = "6379" + +class CeleryConfig: + broker_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/0" + result_backend = f"redis://{REDIS_HOST}:{REDIS_PORT}/0" + broker_connection_retry_on_startup = True + imports = ( + "superset.sql_lab", + "superset.tasks.scheduler", + "superset.tasks.thumbnails", + "superset.tasks.cache", + ) + worker_prefetch_multiplier = 10 + task_acks_late = True + beat_schedule = { + "reports.scheduler": { + "task": "reports.scheduler", + "schedule": crontab(minute="*", hour="*"), + }, + "reports.prune_log": { + "task": "reports.prune_log", + "schedule": crontab(minute=0, hour=0), + }, + } + +CELERY_CONFIG = CeleryConfig + +# SMTP settings pointing to MailHog +SMTP_HOST = "localhost" +SMTP_PORT = 1025 +SMTP_STARTTLS = False +SMTP_SSL = False +SMTP_USER = "" +SMTP_PASSWORD = "" +SMTP_MAIL_FROM = "superset@localhost" + +# Must match where your frontend is running +WEBDRIVER_BASEURL = "http://localhost:9000/" + +ALERT_REPORTS_EXECUTE_AS = [ExecutorType.OWNER] + +FEATURE_FLAGS = { + "ALERT_REPORTS": True, + # Recommended for better screenshot support (WebGL/DeckGL charts) + "PLAYWRIGHT_REPORTS_AND_THUMBNAILS": True, +} +``` + +:::note +Do not include `"superset.tasks.async_queries"` in `CeleryConfig.imports` unless you need Global Async Queries. That module accesses `current_app.config` at import time and will crash the worker with a "Working outside of application context" error. +::: + +### Starting the Services + +Start MailHog, then Celery Beat and Worker in separate terminals: + +```bash +# Terminal 1 - MailHog (SMTP on :1025, Web UI on :8025) +MailHog + +# Terminal 2 - Celery Beat (scheduler) +celery --app=superset.tasks.celery_app:app beat --loglevel=info + +# Terminal 3 - Celery Worker (executor) +celery --app=superset.tasks.celery_app:app worker --concurrency=1 --loglevel=info +``` + +Use `--concurrency=1` to limit resource usage on your dev machine. + +### Verifying the Setup + +1. **Beat** should log `Scheduler: Sending due task reports.scheduler (reports.scheduler)` once per minute +2. **Worker** should log `Scheduling alert eta: ` for each active report +3. Create a test report in **Settings > Alerts & Reports** with a `* * * * *` cron schedule +4. Check **http://localhost:8025** (MailHog web UI) for the email within 1-2 minutes + +### Troubleshooting + +| Problem | Solution | +|---|---| +| Beat shows no output | Ensure `beat_schedule` is defined in your `CeleryConfig` and `--loglevel=info` is set | +| "Report Schedule is still working, refusing to re-compute" | Previous executions are stuck. Reset with: `UPDATE report_schedule SET last_state = 'Not triggered' WHERE id = ;` | +| Task backlog overwhelming the worker | Flush Redis: `redis-cli FLUSHDB`, then restart Beat and Worker | +| Screenshot timeout | Ensure your frontend dev server is running and `WEBDRIVER_BASEURL` matches its URL | + --- *This documentation is under active development. Check back soon for updates!* diff --git a/docs/package.json b/docs/package.json index 9eb76e32e8e6..3da64186be66 100644 --- a/docs/package.json +++ b/docs/package.json @@ -68,9 +68,9 @@ "@storybook/theming": "^8.6.15", "@superset-ui/core": "^0.20.4", "@swc/core": "^1.15.21", - "antd": "^6.3.4", - "baseline-browser-mapping": "^2.10.11", - "caniuse-lite": "^1.0.30001781", + "antd": "^6.3.5", + "baseline-browser-mapping": "^2.10.13", + "caniuse-lite": "^1.0.30001784", "docusaurus-plugin-openapi-docs": "^4.6.0", "docusaurus-theme-openapi-docs": "^4.6.0", "js-yaml": "^4.1.1", @@ -106,7 +106,7 @@ "globals": "^17.4.0", "prettier": "^3.8.1", "typescript": "~5.9.3", - "typescript-eslint": "^8.57.2", + "typescript-eslint": "^8.58.0", "webpack": "^5.105.4" }, "browserslist": { diff --git a/docs/yarn.lock b/docs/yarn.lock index 448ac8d543d3..1cb2004c2eae 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -227,7 +227,7 @@ resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz" integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA== -"@ant-design/icons@^6.1.0", "@ant-design/icons@^6.1.1": +"@ant-design/icons@^6.1.1": version "6.1.1" resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.1.1.tgz#068963d3de44ff7034dce32c9cec3ff7d343fe6b" integrity sha512-AMT4N2y++TZETNHiM77fs4a0uPVCJGuL5MTonk13Pvv7UN7sID1cNEZOc1qNqx6zLKAOilTEFAdAoAFKa0U//Q== @@ -3075,10 +3075,10 @@ dependencies: "@babel/runtime" "^7.18.0" -"@rc-component/motion@^1.0.0", "@rc-component/motion@^1.1.3", "@rc-component/motion@^1.1.4", "@rc-component/motion@^1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@rc-component/motion/-/motion-1.3.1.tgz#1e56b06841ee677261251e6e69fedc8d73e65b22" - integrity sha512-Wo1mkd0tCcHtvYvpPOmlYJz546z16qlsiwaygmW7NPJpOZOF9GBjhGzdzZSsC2lEJ1IUkWLF4gMHlRA1aSA+Yw== +"@rc-component/motion@^1.0.0", "@rc-component/motion@^1.1.3", "@rc-component/motion@^1.1.4", "@rc-component/motion@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@rc-component/motion/-/motion-1.3.2.tgz#bd96e0fd16ee9d98c1d9be14198f003e367d8feb" + integrity sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ== dependencies: "@rc-component/util" "^1.2.0" clsx "^2.1.1" @@ -3159,10 +3159,10 @@ "@rc-component/util" "^1.3.0" clsx "^2.1.1" -"@rc-component/resize-observer@^1.0.0", "@rc-component/resize-observer@^1.0.1", "@rc-component/resize-observer@^1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.1.tgz" - integrity sha512-NfXXMmiR+SmUuKE1NwJESzEUYUFWIDUn2uXpxCTOLwiRUUakd62DRNFjRJArgzyFW8S5rsL4aX5XlyIXyC/vRA== +"@rc-component/resize-observer@^1.0.0", "@rc-component/resize-observer@^1.0.1", "@rc-component/resize-observer@^1.1.1", "@rc-component/resize-observer@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz#5897e65d7fed5c6e768dcfd8bdec181a3309a98f" + integrity sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q== dependencies: "@rc-component/util" "^1.2.0" @@ -5060,100 +5060,100 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@8.57.2", "@typescript-eslint/eslint-plugin@^8.52.0": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz#ad0dcefeca9c2ecbe09f730d478063666aee010b" - integrity sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w== +"@typescript-eslint/eslint-plugin@8.58.0", "@typescript-eslint/eslint-plugin@^8.52.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz#ad40e492f1931f46da1bd888e52b9e56df9063aa" + integrity sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg== dependencies: "@eslint-community/regexpp" "^4.12.2" - "@typescript-eslint/scope-manager" "8.57.2" - "@typescript-eslint/type-utils" "8.57.2" - "@typescript-eslint/utils" "8.57.2" - "@typescript-eslint/visitor-keys" "8.57.2" + "@typescript-eslint/scope-manager" "8.58.0" + "@typescript-eslint/type-utils" "8.58.0" + "@typescript-eslint/utils" "8.58.0" + "@typescript-eslint/visitor-keys" "8.58.0" ignore "^7.0.5" natural-compare "^1.4.0" - ts-api-utils "^2.4.0" + ts-api-utils "^2.5.0" -"@typescript-eslint/parser@8.57.2", "@typescript-eslint/parser@^8.56.1": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.57.2.tgz#b819955e39f976c0d4f95b5ed67fe22f85cd6898" - integrity sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA== +"@typescript-eslint/parser@8.58.0", "@typescript-eslint/parser@^8.56.1": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.58.0.tgz#da04ece1967b6c2fe8f10c3473dabf3825795ef7" + integrity sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA== dependencies: - "@typescript-eslint/scope-manager" "8.57.2" - "@typescript-eslint/types" "8.57.2" - "@typescript-eslint/typescript-estree" "8.57.2" - "@typescript-eslint/visitor-keys" "8.57.2" + "@typescript-eslint/scope-manager" "8.58.0" + "@typescript-eslint/types" "8.58.0" + "@typescript-eslint/typescript-estree" "8.58.0" + "@typescript-eslint/visitor-keys" "8.58.0" debug "^4.4.3" -"@typescript-eslint/project-service@8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.2.tgz#dfbc7777f9f633f2b06b558cda3836e76f856e3c" - integrity sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw== +"@typescript-eslint/project-service@8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.58.0.tgz#66ceda0aabf7427aec3e2713fa43eb278dead2aa" + integrity sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.57.2" - "@typescript-eslint/types" "^8.57.2" + "@typescript-eslint/tsconfig-utils" "^8.58.0" + "@typescript-eslint/types" "^8.58.0" debug "^4.4.3" -"@typescript-eslint/scope-manager@8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz#734dcde40677f430b5d963108337295bdbc09dae" - integrity sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw== +"@typescript-eslint/scope-manager@8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz#e304142775e49a1b7ac3c8bf2536714447c72cab" + integrity sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ== dependencies: - "@typescript-eslint/types" "8.57.2" - "@typescript-eslint/visitor-keys" "8.57.2" + "@typescript-eslint/types" "8.58.0" + "@typescript-eslint/visitor-keys" "8.58.0" -"@typescript-eslint/tsconfig-utils@8.57.2", "@typescript-eslint/tsconfig-utils@^8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz#cf82dc11e884103ec13188a7352591efaa1a887e" - integrity sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw== +"@typescript-eslint/tsconfig-utils@8.58.0", "@typescript-eslint/tsconfig-utils@^8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz#c5a8edb21f31e0fdee565724e1b984171c559482" + integrity sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A== -"@typescript-eslint/type-utils@8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz#3ec65a94e73776252991a3cf0a15d220734c28f5" - integrity sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg== +"@typescript-eslint/type-utils@8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz#ce0e72cd967ffbbe8de322db6089bd4374be352f" + integrity sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg== dependencies: - "@typescript-eslint/types" "8.57.2" - "@typescript-eslint/typescript-estree" "8.57.2" - "@typescript-eslint/utils" "8.57.2" + "@typescript-eslint/types" "8.58.0" + "@typescript-eslint/typescript-estree" "8.58.0" + "@typescript-eslint/utils" "8.58.0" debug "^4.4.3" - ts-api-utils "^2.4.0" - -"@typescript-eslint/types@8.57.2", "@typescript-eslint/types@^8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.2.tgz#efe0da4c28b505ed458f113aa960dce2c5c671f4" - integrity sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA== - -"@typescript-eslint/typescript-estree@8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz#432e61a6cf2ab565837da387e5262c159672abea" - integrity sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA== - dependencies: - "@typescript-eslint/project-service" "8.57.2" - "@typescript-eslint/tsconfig-utils" "8.57.2" - "@typescript-eslint/types" "8.57.2" - "@typescript-eslint/visitor-keys" "8.57.2" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.58.0", "@typescript-eslint/types@^8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.58.0.tgz#e94ae7abdc1c6530e71183c1007b61fa93112a5a" + integrity sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww== + +"@typescript-eslint/typescript-estree@8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz#ed233faa8e2f2a2e1357c3e7d553d6465a0ee59a" + integrity sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA== + dependencies: + "@typescript-eslint/project-service" "8.58.0" + "@typescript-eslint/tsconfig-utils" "8.58.0" + "@typescript-eslint/types" "8.58.0" + "@typescript-eslint/visitor-keys" "8.58.0" debug "^4.4.3" minimatch "^10.2.2" semver "^7.7.3" tinyglobby "^0.2.15" - ts-api-utils "^2.4.0" + ts-api-utils "^2.5.0" -"@typescript-eslint/utils@8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.57.2.tgz#46a8974c24326fb8899486728428a0f1a3115014" - integrity sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg== +"@typescript-eslint/utils@8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.58.0.tgz#21a74a7963b0d288b719a4121c7dd555adaab3c3" + integrity sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA== dependencies: "@eslint-community/eslint-utils" "^4.9.1" - "@typescript-eslint/scope-manager" "8.57.2" - "@typescript-eslint/types" "8.57.2" - "@typescript-eslint/typescript-estree" "8.57.2" + "@typescript-eslint/scope-manager" "8.58.0" + "@typescript-eslint/types" "8.58.0" + "@typescript-eslint/typescript-estree" "8.58.0" -"@typescript-eslint/visitor-keys@8.57.2": - version "8.57.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz#a5c9605774247336c0412beb7dc288ab2a07c11e" - integrity sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw== +"@typescript-eslint/visitor-keys@8.58.0": + version "8.58.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz#2abd55a4be70fd55967aceaba4330b9ba9f45189" + integrity sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ== dependencies: - "@typescript-eslint/types" "8.57.2" + "@typescript-eslint/types" "8.58.0" eslint-visitor-keys "^5.0.0" "@ungap/structured-clone@^1.0.0": @@ -5406,9 +5406,9 @@ ajv-keywords@^5.1.0: fast-deep-equal "^3.1.3" ajv@^6.12.4, ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -5416,9 +5416,9 @@ ajv@^6.12.4, ajv@^6.12.5: uri-js "^4.2.2" ajv@^8.0.0, ajv@^8.11.0, ajv@^8.9.0: - version "8.17.1" - resolved "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz" - integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + version "8.18.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== dependencies: fast-deep-equal "^3.1.3" fast-uri "^3.0.1" @@ -5507,16 +5507,16 @@ ansi-styles@^6.1.0: resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== -antd@^6.3.4: - version "6.3.4" - resolved "https://registry.yarnpkg.com/antd/-/antd-6.3.4.tgz#6c11f91da3b4cd87fd4be93876f8ce75c443bd2c" - integrity sha512-Bu6JivPP7bFfYIdVj+61dxhwSOz+A3m0W7PlDasFGC3H3sNMYQ9gJXZoo11/rQh7pTlOQa351q5Ig/zjI98XYw== +antd@^6.3.5: + version "6.3.5" + resolved "https://registry.yarnpkg.com/antd/-/antd-6.3.5.tgz#3f231e25306c4213925d6476ef9c2ec21cf53b7e" + integrity sha512-8BPz9lpZWQm42PTx7yL4KxWAotVuqINiKcoYRcLtdd5BFmAcAZicVyFTnBJyRDlzGZFZeRW3foGu6jXYFnej6Q== dependencies: "@ant-design/colors" "^8.0.1" "@ant-design/cssinjs" "^2.1.2" "@ant-design/cssinjs-utils" "^2.1.2" "@ant-design/fast-color" "^3.0.1" - "@ant-design/icons" "^6.1.0" + "@ant-design/icons" "^6.1.1" "@ant-design/react-slick" "~2.0.0" "@babel/runtime" "^7.28.4" "@rc-component/cascader" "~1.14.0" @@ -5532,7 +5532,7 @@ antd@^6.3.4: "@rc-component/input-number" "~1.6.2" "@rc-component/mentions" "~1.6.0" "@rc-component/menu" "~1.2.0" - "@rc-component/motion" "^1.3.1" + "@rc-component/motion" "^1.3.2" "@rc-component/mutate-observer" "^2.0.1" "@rc-component/notification" "~1.2.0" "@rc-component/pagination" "~1.2.0" @@ -5540,7 +5540,7 @@ antd@^6.3.4: "@rc-component/progress" "~1.0.2" "@rc-component/qrcode" "~1.1.1" "@rc-component/rate" "~1.0.1" - "@rc-component/resize-observer" "^1.1.1" + "@rc-component/resize-observer" "^1.1.2" "@rc-component/segmented" "~1.3.0" "@rc-component/select" "~1.6.15" "@rc-component/slider" "~1.0.1" @@ -5819,10 +5819,10 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.10.11, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: - version "2.10.11" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz#957bea71ccc2e9854287c2575a037d36b3a94b73" - integrity sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg== +baseline-browser-mapping@^2.10.13, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: + version "2.10.13" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz#5a154cc4589193015a274e3d18319b0d76b9224e" + integrity sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw== batch@0.6.1: version "0.6.1" @@ -5911,24 +5911,24 @@ boxen@^7.0.0: wrap-ansi "^8.1.0" brace-expansion@^1.1.7: - version "1.1.12" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz" - integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + version "1.1.13" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" + integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + version "2.0.3" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.3.tgz#0493338bdd58e319b1039c67cf7ee439892c01d9" + integrity sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA== dependencies: balanced-match "^1.0.0" -brace-expansion@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f" - integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw== +brace-expansion@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== dependencies: balanced-match "^4.0.2" @@ -6067,10 +6067,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.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001781: - version "1.0.30001781" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz#344b47c03eb8168b79c3c158b872bcfbdd02a400" - integrity sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001784: + version "1.0.30001784" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz#bdf9733a0813ccfb5ab4d02f2127e62ee4c6b718" + integrity sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw== ccount@^2.0.0: version "2.0.1" @@ -9073,14 +9073,14 @@ immer@^11.0.0: integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q== immutable@^3.x.x: - version "3.8.2" - resolved "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz" - integrity sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg== + version "3.8.3" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.3.tgz#0a8d2494a94d4b2d4f0e99986e74dd25d1e9a859" + integrity sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg== immutable@^5.0.2: - version "5.1.4" - resolved "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz" - integrity sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA== + version "5.1.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165" + integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" @@ -9657,21 +9657,21 @@ js-yaml-loader@^1.2.2: js-yaml@4.1.0: version "4.1.0" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: argparse "^2.0.1" js-yaml@=4.1.1, js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.1.1" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" js-yaml@^3.13.1: version "3.14.2" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0" integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== dependencies: argparse "^1.0.7" @@ -10050,12 +10050,12 @@ lodash.uniq@^4.5.0: lodash@4.17.21: version "4.17.21" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4: version "4.17.23" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== longest-streak@^3.0.0: @@ -11160,31 +11160,31 @@ minimalistic-assert@^1.0.0: resolved "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@3.1.2, minimatch@^3.1.1, minimatch@^3.1.2: +minimatch@3.1.2: version "3.1.2" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: brace-expansion "^1.1.7" -minimatch@^10.2.1: - version "10.2.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.2.tgz#361603ee323cfb83496fea2ae17cc44ea4e1f99f" - integrity sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw== +minimatch@^10.2.1, minimatch@^10.2.2: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== dependencies: - brace-expansion "^5.0.2" + brace-expansion "^5.0.5" -minimatch@^10.2.2: - version "10.2.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" - integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== +minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: - brace-expansion "^5.0.2" + brace-expansion "^1.1.7" minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + version "5.1.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== dependencies: brace-expansion "^2.0.1" @@ -11849,20 +11849,20 @@ path-parse@^1.0.7: path-to-regexp@3.3.0: version "3.3.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== path-to-regexp@^1.7.0: version "1.9.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.9.0.tgz#5dc0753acbf8521ca2e0f137b4578b917b10cf24" integrity sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g== dependencies: isarray "0.0.1" path-to-regexp@~0.1.12: - version "0.1.12" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz" - integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== + version "0.1.13" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" + integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== path-type@^4.0.0: version "4.0.0" @@ -11888,14 +11888,14 @@ picocolors@^1.0.0, picocolors@^1.1.1: integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== pirates@^4.0.1: version "4.0.7" @@ -13764,7 +13764,7 @@ serialize-error@^8.1.0: serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: version "6.0.2" - resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== dependencies: randombytes "^2.1.0" @@ -14680,10 +14680,10 @@ trough@^2.0.0: resolved "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz" integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== -ts-api-utils@^2.4.0: - version "2.4.0" - resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz" - integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== ts-dedent@^2.0.0, ts-dedent@^2.2.0: version "2.2.0" @@ -14804,15 +14804,15 @@ types-ramda@^0.30.1: dependencies: ts-toolbelt "^9.6.0" -typescript-eslint@^8.57.2: - version "8.57.2" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.57.2.tgz#d64c6648dda5b15176708701537ab0b55ba3c83d" - integrity sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A== +typescript-eslint@^8.58.0: + version "8.58.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.58.0.tgz#5758b1b68ae7ec05d756b98c63a1f6953a01172b" + integrity sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA== dependencies: - "@typescript-eslint/eslint-plugin" "8.57.2" - "@typescript-eslint/parser" "8.57.2" - "@typescript-eslint/typescript-estree" "8.57.2" - "@typescript-eslint/utils" "8.57.2" + "@typescript-eslint/eslint-plugin" "8.58.0" + "@typescript-eslint/parser" "8.58.0" + "@typescript-eslint/typescript-estree" "8.58.0" + "@typescript-eslint/utils" "8.58.0" typescript@~5.9.3: version "5.9.3" @@ -15680,11 +15680,16 @@ yaml-ast-parser@0.0.43: resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz" integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== -yaml@1.10.2, yaml@^1.10.0: +yaml@1.10.2: version "1.10.2" - resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^1.10.0: + version "1.10.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3" + integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA== + yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" diff --git a/superset-embedded-sdk/package-lock.json b/superset-embedded-sdk/package-lock.json index ffdbbeea6420..4b34612ff218 100644 --- a/superset-embedded-sdk/package-lock.json +++ b/superset-embedded-sdk/package-lock.json @@ -7025,9 +7025,9 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "engines": { "node": ">=8.6" @@ -7121,15 +7121,6 @@ } ] }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7306,26 +7297,6 @@ "node": ">=10" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", @@ -7355,15 +7326,6 @@ "semver": "bin/semver.js" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -7590,15 +7552,14 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -13159,9 +13120,9 @@ "dev": true }, "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true }, "pify": { @@ -13220,15 +13181,6 @@ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "dev": true }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, "react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -13359,12 +13311,6 @@ "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", "dev": true }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, "schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", @@ -13383,15 +13329,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true }, - "serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, "shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -13563,15 +13500,14 @@ } }, "terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "requires": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" } }, diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js index db01c79e74ed..fcca8cbf5ae2 100644 --- a/superset-frontend/.eslintrc.js +++ b/superset-frontend/.eslintrc.js @@ -127,7 +127,6 @@ module.exports = { }, plugins: [ 'import', - 'file-progress', 'lodash', 'theme-colors', 'icons', diff --git a/superset-frontend/cypress-base/package-lock.json b/superset-frontend/cypress-base/package-lock.json index 478c80717a2a..01247e07657c 100644 --- a/superset-frontend/cypress-base/package-lock.json +++ b/superset-frontend/cypress-base/package-lock.json @@ -3167,9 +3167,9 @@ "integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q==" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11132,9 +11132,9 @@ "integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q==" }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index c2b40e195294..29491795cefc 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -86,7 +86,7 @@ "dom-to-pdf": "^0.3.2", "echarts": "^5.6.0", "fast-glob": "^3.3.2", - "fs-extra": "^11.3.3", + "fs-extra": "^11.3.4", "fuse.js": "^7.1.0", "geolib": "^3.3.4", "geostyler": "^18.3.1", @@ -103,7 +103,7 @@ "json-stringify-pretty-compact": "^2.0.0", "lodash": "^4.17.23", "mapbox-gl": "^3.20.0", - "markdown-to-jsx": "^9.7.6", + "markdown-to-jsx": "^9.7.13", "match-sorter": "^8.2.0", "memoize-one": "^5.2.1", "mousetrap": "^1.6.5", @@ -190,7 +190,7 @@ "@storybook/test-runner": "^0.17.0", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.15.18", - "@swc/plugin-emotion": "^14.6.0", + "@swc/plugin-emotion": "^14.7.0", "@swc/plugin-transform-imports": "^12.5.0", "@testing-library/dom": "^8.20.1", "@testing-library/jest-dom": "^6.9.1", @@ -203,7 +203,7 @@ "@types/js-levenshtein": "^1.1.3", "@types/json-bigint": "^1.0.4", "@types/mousetrap": "^1.6.15", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "@types/react": "^17.0.83", "@types/react-dom": "^17.0.26", "@types/react-loadable": "^5.5.11", @@ -220,7 +220,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "babel-jest": "^30.0.2", - "babel-loader": "^10.0.0", + "babel-loader": "^10.1.0", "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-lodash": "^3.3.4", @@ -236,7 +236,6 @@ "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-cypress": "^3.6.0", - "eslint-plugin-file-progress": "^1.5.0", "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", "eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons", "eslint-plugin-import": "^2.32.0", @@ -260,11 +259,11 @@ "jest-html-reporter": "^4.3.0", "jest-websocket-mock": "^2.5.0", "js-yaml-loader": "^1.2.2", - "jsdom": "^28.1.0", + "jsdom": "^29.0.1", "lerna": "^9.0.4", "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.1", - "open-cli": "^8.0.0", + "open-cli": "^9.0.0", "oxlint": "^1.56.0", "po2json": "^0.4.5", "prettier": "3.8.1", @@ -289,7 +288,7 @@ "vm-browserify": "^1.1.2", "wait-on": "^9.0.4", "webpack": "^5.105.4", - "webpack-bundle-analyzer": "^5.2.0", + "webpack-bundle-analyzer": "^5.3.0", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.3", "webpack-manifest-plugin": "^5.0.1", @@ -337,13 +336,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", @@ -470,23 +462,26 @@ "link": true }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", + "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -494,43 +489,53 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -2651,6 +2656,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -2713,9 +2729,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", - "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -2757,9 +2773,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", - "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -2773,8 +2789,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.1", - "@csstools/css-calc": "^3.0.0" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { "node": ">=20.19.0" @@ -2807,23 +2823,6 @@ "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", - "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0" - }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", @@ -3089,13 +3088,13 @@ } }, "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=14.17.0" } }, "node_modules/@dnd-kit/accessibility": { @@ -5850,9 +5849,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13609,9 +13608,9 @@ } }, "node_modules/@swc/plugin-emotion": { - "version": "14.6.0", - "resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.6.0.tgz", - "integrity": "sha512-YyUalzWUeKgnm5SFxR6ZmIY06J6ffok/XyZjlzrLi5GdiGfSJVtmVhJxqNYNnT4CFa6pbo6Pp8yXeLfjNnSJzQ==", + "version": "14.7.0", + "resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.7.0.tgz", + "integrity": "sha512-RwYrsxia8GKh2qLHWwymcfCeP6C5gAkssB2YtBRhP/qlKCxXYfv808buEXkCYvyGIY+bN3XziKXCuAi+waA5pQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -13778,6 +13777,24 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -14822,9 +14839,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -15487,9 +15504,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15647,9 +15664,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15900,9 +15917,9 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18173,9 +18190,9 @@ } }, "node_modules/babel-loader": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", - "integrity": "sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.1.0.tgz", + "integrity": "sha512-5HTUZa013O4SWEYlJDHexrqSIYkWatfA9w/ZZQa7V2nMc0dRWkfu/0pmioC7XMYm8M7Z/3+q42NWj6e+fAT0MQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18185,8 +18202,17 @@ "node": "^18.20.0 || ^20.10.0 || >=22.0.0" }, "peerDependencies": { - "@babel/core": "^7.12.0", + "@babel/core": "^7.12.0 || ^8.0.0-beta.1", + "@rspack/core": "^1.0.0 || ^2.0.0-0", "webpack": ">=5.61.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/babel-plugin-dynamic-import-node": { @@ -18779,9 +18805,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -21903,46 +21929,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/cssstyle": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", - "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.2", - "@csstools/css-syntax-patches-for-csstree": "^1.0.26", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -22712,9 +22698,9 @@ } }, "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -24202,20 +24188,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-file-progress": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-file-progress/-/eslint-plugin-file-progress-1.5.0.tgz", - "integrity": "sha512-get8oNfacIagP+igSzrEZhepPgodtdwACVeKQsE1fVvTL15tZvgCv8K4B2lKT4FZOZOyhxSkQGnWyjEOx1uoIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "nanospinner": "^1.1.0", - "picocolors": "^1.0.1" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, "node_modules/eslint-plugin-i18n-strings": { "resolved": "eslint-rules/eslint-plugin-i18n-strings", "link": true @@ -24604,9 +24576,9 @@ } }, "node_modules/eslint-plugin-testing-library/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -25331,21 +25303,24 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz", - "integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.5.tgz", + "integrity": "sha512-cK9c5I/DwIOI7/Q7AlGN3DuTdwN61gwSfL8rvuVPK+0mcCNHHGxRrpiFtaZZRfRMJL3Gl8B2AFlBG6qXf03w9A==", "funding": [ { "type": "github", @@ -25523,18 +25498,19 @@ "license": "MIT" }, "node_modules/file-type": { - "version": "18.7.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", - "integrity": "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "dev": true, "license": "MIT", "dependencies": { - "readable-web-to-node-stream": "^3.0.2", - "strtok3": "^7.0.0", - "token-types": "^5.0.1" + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" }, "engines": { - "node": ">=14.16" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -25550,9 +25526,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -26216,9 +26192,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -26683,9 +26659,9 @@ } }, "node_modules/geostyler-sld-parser/node_modules/fast-xml-parser": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", - "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", "funding": [ { "type": "github", @@ -26694,17 +26670,18 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "node_modules/geostyler-sld-parser/node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", @@ -26970,19 +26947,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stdin": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", - "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -27463,9 +27427,9 @@ "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -28695,16 +28659,6 @@ "webpack": "^5.0.0" } }, - "node_modules/imports-loader/node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -29330,6 +29284,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -30632,9 +30599,9 @@ "license": "MIT" }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -32626,9 +32593,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -33543,36 +33510,36 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -33583,10 +33550,35 @@ } } }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, "node_modules/jsdom/node_modules/@exodus/bytes": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", - "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, "license": "MIT", "engines": { @@ -33602,28 +33594,32 @@ } }, "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "dev": true, "license": "MIT", "optional": true, "peer": true, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, - "node_modules/jsdom/node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">= 14" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, "node_modules/jsdom/node_modules/entities": { @@ -33639,33 +33635,22 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/jsdom/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 14" + "node": "20 || >=22" } }, - "node_modules/jsdom/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } + "license": "CC0-1.0" }, "node_modules/jsdom/node_modules/parse5": { "version": "8.0.0", @@ -33681,29 +33666,29 @@ } }, "node_modules/jsdom/node_modules/tldts": { - "version": "7.0.16", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", - "integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==", + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.16" + "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/jsdom/node_modules/tldts-core": { - "version": "7.0.16", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz", - "integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==", + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", "dev": true, "license": "MIT" }, "node_modules/jsdom/node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -33921,9 +33906,9 @@ } }, "node_modules/jspdf": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", - "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", @@ -35689,9 +35674,9 @@ } }, "node_modules/markdown-to-jsx": { - "version": "9.7.6", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.7.6.tgz", - "integrity": "sha512-oPckbBhWv/d2HmYzSv68g2UBTONmrFYlqUd+juolxTplJImhDEKFgAEcnxSTeZ1HISKzKxm+mVeUYP7OUhglJQ==", + "version": "9.7.13", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.7.13.tgz", + "integrity": "sha512-twSoD1A2RMx+wyYTNvHvf4JTzEr4SfkgycoSRwjbezejKhqdIXHrHaA3uEm24ET/ZLWrwSzOyWahmVULC+tTbw==", "license": "MIT", "engines": { "node": ">= 18" @@ -35975,9 +35960,9 @@ } }, "node_modules/mem-fs-editor/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -36903,16 +36888,6 @@ "node": "^18 || >=20" } }, - "node_modules/nanospinner": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz", - "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.1.1" - } - }, "node_modules/napi-postinstall": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.5.tgz", @@ -38176,23 +38151,22 @@ } }, "node_modules/open-cli": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/open-cli/-/open-cli-8.0.0.tgz", - "integrity": "sha512-3muD3BbfLyzl+aMVSEfn2FfOqGdPYR0O4KNnxXsLEPE2q9OSjBfJAaB6XKbrUzLgymoSMejvb5jpXJfru/Ko2A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/open-cli/-/open-cli-9.0.0.tgz", + "integrity": "sha512-4UHkLVm4tUM/ardg66uY3x1icgfCnunks5eFVFBzASO3b13Ow2Md3xs9YT7yXWFjXOBpauIeh/N9fvbziU1wkg==", "dev": true, "license": "MIT", "dependencies": { - "file-type": "^18.7.0", - "get-stdin": "^9.0.0", - "meow": "^12.1.1", - "open": "^10.0.0", - "tempy": "^3.1.0" + "file-type": "^21.3.4", + "meow": "^14.1.0", + "open": "^11.0.0", + "tempy": "^3.2.0" }, "bin": { "open-cli": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -38211,49 +38185,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open-cli/node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open-cli/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=16.10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/open-cli/node_modules/open": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", - "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", "dev": true, "license": "MIT", "dependencies": { - "default-browser": "^5.2.1", + "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -39045,6 +39005,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -39094,9 +39069,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "dev": true, "license": "MIT" }, @@ -39132,20 +39107,6 @@ "pbf": "bin/pbf" } }, - "node_modules/peek-readable": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.3.1.tgz", - "integrity": "sha512-GVlENSDW6KHaXcd9zkZltB7tCLosKB/4Hg0fqBJkAoBgYG2Tn1xtMgXtSUuMU9AK/gCm/tTdT8mgAeF4YNeeqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -39996,22 +39957,25 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postcss/node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/potpack": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==", "license": "ISC" }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/preact": { "version": "10.28.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", @@ -42423,23 +42387,6 @@ "node": ">= 6" } }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", - "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -44195,9 +44142,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", - "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -44812,9 +44759,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.1.tgz", - "integrity": "sha512-4+TN2b3tqOCd/kaGRJ/sTYA0tR0mdXx26ipdolxcwtJVqEnqNYvlCAt1q3ypy4QMlYus+Zh34RNtYLoq2oQ4IA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -45754,17 +45701,16 @@ "license": "MIT" }, "node_modules/strtok3": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.1.1.tgz", - "integrity": "sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "dev": true, "license": "MIT", "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^5.1.3" + "@tokenizer/token": "^0.3.0" }, "engines": { - "node": ">=16" + "node": ">=18" }, "funding": { "type": "github", @@ -46033,10 +45979,20 @@ "streamx": "^2.12.5" } }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, "node_modules/tempy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", - "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -46065,16 +46021,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tempy/node_modules/temp-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, "node_modules/terser": { "version": "5.37.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", @@ -46554,12 +46500,13 @@ } }, "node_modules/token-types": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", - "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "dev": true, "license": "MIT", "dependencies": { + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" }, @@ -47723,6 +47670,19 @@ "node": ">=0.8.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ultimate-pagination": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ultimate-pagination/-/ultimate-pagination-1.0.0.tgz", @@ -47761,9 +47721,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", "dev": true, "license": "MIT", "engines": { @@ -48975,19 +48935,18 @@ } }, "node_modules/webpack-bundle-analyzer": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.2.0.tgz", - "integrity": "sha512-Etrauj1wYO/xjiz/Vfd6bW1lG9fEhrJpNmu10tv0X9kv+gyY3qiE09uYepqg1Xd0PxOvllRXwWYWjtQYoO/glQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.0.tgz", + "integrity": "sha512-PEhAoqiJ+47d0uLMx/+zo5XOvaU+Vk6N2ZLht7H3n09QLy/fhyvqGNwjdRUHJDgMN8crBR2ZwVHkIswT3Xuawg==", "dev": true, "license": "MIT", "dependencies": { - "@discoveryjs/json-ext": "0.5.7", + "@discoveryjs/json-ext": "^0.6.3", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "html-escaper": "^2.0.2", + "commander": "^14.0.2", + "escape-string-regexp": "^5.0.0", + "html-escaper": "^3.0.3", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^3.0.2", @@ -49027,15 +48986,35 @@ } }, "node_modules/webpack-bundle-analyzer/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=20" } }, + "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -49101,16 +49080,6 @@ } } }, - "node_modules/webpack-cli/node_modules/@discoveryjs/json-ext": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", - "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.17.0" - } - }, "node_modules/webpack-cli/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -49640,9 +49609,9 @@ } }, "node_modules/whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", "dependencies": { @@ -49930,6 +49899,39 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xlsx": { "version": "0.20.3", "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -50593,7 +50595,7 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.3", + "fs-extra": "^11.3.4", "jest": "^30.3.0", "yeoman-test": "^11.3.1" }, @@ -50639,9 +50641,9 @@ } }, "packages/generator-superset/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -50942,7 +50944,7 @@ "ag-grid-react": "35.0.1", "brace": "^0.11.1", "classnames": "^2.5.1", - "core-js": "^3.48.0", + "core-js": "^3.49.0", "csstype": "^3.2.3", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", @@ -50984,7 +50986,7 @@ "@types/d3-time-format": "^4.0.3", "@types/jquery": "^3.5.33", "@types/lodash": "^4.17.24", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "@types/prop-types": "^15.7.15", "@types/react-syntax-highlighter": "^15.5.13", "@types/react-table": "^7.7.20", @@ -51033,9 +51035,9 @@ "license": "MIT" }, "packages/superset-ui-core/node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -51064,27 +51066,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/superset-ui-core/node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, "packages/superset-ui-core/node_modules/mdast-util-find-and-replace": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz", @@ -51918,15 +51899,6 @@ "url": "https://opencollective.com/unified" } }, - "packages/superset-ui-core/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "packages/superset-ui-core/node_modules/unist-util-is": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", @@ -52217,9 +52189,9 @@ "version": "0.20.4", "license": "Apache-2.0", "dependencies": { - "@deck.gl/aggregation-layers": "~9.2.5", + "@deck.gl/aggregation-layers": "~9.2.9", "@deck.gl/core": "~9.2.5", - "@deck.gl/extensions": "~9.2.5", + "@deck.gl/extensions": "~9.2.9", "@deck.gl/geo-layers": "~9.2.5", "@deck.gl/layers": "~9.2.5", "@deck.gl/mesh-layers": "~9.2.5", @@ -52264,6 +52236,41 @@ "react-map-gl": "^6.1.19" } }, + "plugins/legacy-preset-chart-deckgl/node_modules/@deck.gl/aggregation-layers": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.2.11.tgz", + "integrity": "sha512-MRFbBHtMcDkOthxXnMPm6nF08DjFDACaIQsJSyHkdWtLUTSLHsWnOTn/8QbB4ka86WyNyfJy3dibLu/m3ei2ow==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "d3-hexbin": "^0.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "plugins/legacy-preset-chart-deckgl/node_modules/@deck.gl/extensions": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.11.tgz", + "integrity": "sha512-zlpM4Bg1ifBziW1Juiii9NY5gyW2rEhyVTWnhagH/bpTCZ2E73OhnToYt1ouqmoxL6lMtIjhRXz6LPb7tJbHHQ==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, "plugins/legacy-preset-chart-deckgl/node_modules/@deck.gl/mesh-layers": { "version": "9.2.5", "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.5.tgz", @@ -52319,42 +52326,12 @@ "node": ">=12" } }, - "plugins/legacy-preset-chart-deckgl/node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, "plugins/legacy-preset-chart-deckgl/node_modules/internmap": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", "license": "ISC" }, - "plugins/legacy-preset-chart-deckgl/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "plugins/legacy-preset-chart-nvd3": { "name": "@superset-ui/legacy-preset-chart-nvd3", "version": "0.20.3", @@ -52609,7 +52586,7 @@ "license": "Apache-2.0", "dependencies": { "@types/d3-scale": "^4.0.9", - "d3-cloud": "^1.2.9", + "d3-cloud": "^1.2.8", "d3-scale": "^4.0.2" }, "devDependencies": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 3414f112d9bf..b059658b4a7e 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -167,7 +167,7 @@ "dom-to-pdf": "^0.3.2", "echarts": "^5.6.0", "fast-glob": "^3.3.2", - "fs-extra": "^11.3.3", + "fs-extra": "^11.3.4", "fuse.js": "^7.1.0", "geolib": "^3.3.4", "geostyler": "^18.3.1", @@ -184,7 +184,7 @@ "json-stringify-pretty-compact": "^2.0.0", "lodash": "^4.17.23", "mapbox-gl": "^3.20.0", - "markdown-to-jsx": "^9.7.6", + "markdown-to-jsx": "^9.7.13", "match-sorter": "^8.2.0", "memoize-one": "^5.2.1", "mousetrap": "^1.6.5", @@ -271,7 +271,7 @@ "@storybook/test-runner": "^0.17.0", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.15.18", - "@swc/plugin-emotion": "^14.6.0", + "@swc/plugin-emotion": "^14.7.0", "@swc/plugin-transform-imports": "^12.5.0", "@testing-library/dom": "^8.20.1", "@testing-library/jest-dom": "^6.9.1", @@ -284,7 +284,7 @@ "@types/js-levenshtein": "^1.1.3", "@types/json-bigint": "^1.0.4", "@types/mousetrap": "^1.6.15", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "@types/react": "^17.0.83", "@types/react-dom": "^17.0.26", "@types/react-loadable": "^5.5.11", @@ -301,7 +301,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "babel-jest": "^30.0.2", - "babel-loader": "^10.0.0", + "babel-loader": "^10.1.0", "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-lodash": "^3.3.4", @@ -317,7 +317,6 @@ "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-cypress": "^3.6.0", - "eslint-plugin-file-progress": "^1.5.0", "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", "eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons", "eslint-plugin-import": "^2.32.0", @@ -341,11 +340,11 @@ "jest-html-reporter": "^4.3.0", "jest-websocket-mock": "^2.5.0", "js-yaml-loader": "^1.2.2", - "jsdom": "^28.1.0", + "jsdom": "^29.0.1", "lerna": "^9.0.4", "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.1", - "open-cli": "^8.0.0", + "open-cli": "^9.0.0", "oxlint": "^1.56.0", "po2json": "^0.4.5", "prettier": "3.8.1", @@ -370,7 +369,7 @@ "vm-browserify": "^1.1.2", "wait-on": "^9.0.4", "webpack": "^5.105.4", - "webpack-bundle-analyzer": "^5.2.0", + "webpack-bundle-analyzer": "^5.3.0", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.3", "webpack-manifest-plugin": "^5.0.1", diff --git a/superset-frontend/packages/generator-superset/package.json b/superset-frontend/packages/generator-superset/package.json index 78b199b90a56..ca53a09dfe45 100644 --- a/superset-frontend/packages/generator-superset/package.json +++ b/superset-frontend/packages/generator-superset/package.json @@ -35,7 +35,7 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.3", + "fs-extra": "^11.3.4", "jest": "^30.3.0", "yeoman-test": "^11.3.1" }, diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index d8e9b951cf2e..6a065541854c 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -34,7 +34,7 @@ "brace": "^0.11.1", "classnames": "^2.5.1", "csstype": "^3.2.3", - "core-js": "^3.48.0", + "core-js": "^3.49.0", "d3-format": "^3.1.2", "dayjs": "^1.11.20", "d3-interpolate": "^3.0.1", @@ -78,7 +78,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/jquery": "^3.5.33", "@types/lodash": "^4.17.24", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "@types/prop-types": "^15.7.15", "@types/rison": "0.1.0", "@types/seedrandom": "^3.0.8", diff --git a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx index 56230e130f0c..89ec5aa7caff 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx @@ -283,6 +283,16 @@ export function AsyncAceEditor( color: ${token.colorText} !important; } + /* Fix cursor misalignment by ensuring consistent font-family */ + .ace_editor .ace_content { + font-family: ${editorFontFamily} !important; + } + + /* Ensure the text layer uses the same font-family */ + .ace_editor .ace_text-layer { + font-family: ${editorFontFamily} !important; + } + /* Adjust gutter colors */ .ace_editor .ace_gutter { background-color: ${token.colorBgElevated} !important; 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 45656928757a..6b89561627a3 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 @@ -915,6 +915,38 @@ test('"Select all" does not affect disabled options', async () => { expect(await findSelectValue()).not.toHaveTextContent(options[1].label); }); +test('dropdown takes full width of the select input for multi select', async () => { + render( +
+ +
, + ); + await open(); + const dropdown = document.querySelector( + '.ant-select-dropdown', + ) as HTMLElement; + expect(dropdown).toBeInTheDocument(); + const widthValue = parseInt(dropdown.style.width, 10); + expect(Number.isNaN(widthValue) || widthValue === 0).toBe(true); +}); + test('does not fire onChange when searching but no selection', async () => { const onChange = jest.fn(); render( diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx index a5863d1a6f4e..3f5d9cf4bf8c 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx @@ -777,7 +777,7 @@ const Select = forwardRef( options={visibleOptions} optionRender={option => {option.label || option.value}} oneLine={oneLine} - popupMatchSelectWidth={selectAllEnabled ? 168 : true} + popupMatchSelectWidth css={props.css} dropdownAlign={DROPDOWN_ALIGN_BOTTOM} {...props} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json b/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json index 8f908e493bba..2c35cfe20587 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json @@ -24,9 +24,9 @@ "lib" ], "dependencies": { - "@deck.gl/aggregation-layers": "~9.2.5", + "@deck.gl/aggregation-layers": "~9.2.9", "@deck.gl/core": "~9.2.5", - "@deck.gl/extensions": "~9.2.5", + "@deck.gl/extensions": "~9.2.9", "@deck.gl/geo-layers": "~9.2.5", "@deck.gl/layers": "~9.2.5", "@deck.gl/mesh-layers": "~9.2.5", diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts index 1f11498a358d..2ffeaebf2ab7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts @@ -207,13 +207,12 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) { const echartOptions: EChartsCoreOption = { series, xAxis: { - axisLabel: { formatter: xAxisFormatter }, + axisLabel: { formatter: xAxisFormatter, rotate: xAxisLabelRotation }, splitLine: { lineStyle: { type: 'dashed', }, }, - nameRotate: xAxisLabelRotation, interval: xAxisLabelInterval, scale: true, name: bubbleXAxisTitle, @@ -226,13 +225,12 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) { ...getMinAndMaxFromBounds(xAxisType, truncateXAxis, xAxisMin, xAxisMax), }, yAxis: { - axisLabel: { formatter: yAxisFormatter }, + axisLabel: { formatter: yAxisFormatter, rotate: yAxisLabelRotation }, splitLine: { lineStyle: { type: 'dashed', }, }, - nameRotate: yAxisLabelRotation, scale: truncateYAxis, name: bubbleYAxisTitle, nameLocation: 'middle', 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 b1655e38d1d5..71dc91183a94 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -578,9 +578,31 @@ export default function transformProps( : String; const xAxisFormatter = xAxisDataType === GenericDataType.Temporal - ? getXAxisFormatter(xAxisTimeFormat) + ? getXAxisFormatter(xAxisTimeFormat, timeGrainSqla) : String; + const showMaxLabel = xAxisType === AxisType.Time && xAxisLabelRotation === 0; + const deduplicatedFormatter = showMaxLabel + ? (() => { + let lastLabel: string | undefined; + const wrapper = (value: number | string) => { + const label = + typeof xAxisFormatter === 'function' + ? (xAxisFormatter as Function)(value) + : String(value); + if (label === lastLabel) { + return ''; + } + lastLabel = label; + return label; + }; + if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) { + (wrapper as any).id = (xAxisFormatter as any).id; + } + return wrapper; + })() + : xAxisFormatter; + const addYAxisTitleOffset = !!(yAxisTitle || yAxisTitleSecondary) && convertInteger(yAxisTitleMargin) !== 0; @@ -658,9 +680,14 @@ export default function transformProps( nameGap: convertInteger(xAxisTitleMargin), nameLocation: 'middle', axisLabel: { - formatter: xAxisFormatter, + hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0), + formatter: deduplicatedFormatter, rotate: xAxisLabelRotation, interval: xAxisLabelInterval, + ...(showMaxLabel && { + showMaxLabel: true, + alignMaxLabel: 'right', + }), }, minorTick: { show: minorTicks }, minInterval: diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx index 4909ea38a811..5c12e526e9b6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx @@ -17,7 +17,10 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; +import { getColumnLabel, QueryFormColumn } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; import { + checkColumnType, ControlPanelConfig, ControlPanelsContainerProps, ControlSubSectionHeader, @@ -181,6 +184,30 @@ const config: ControlPanelConfig = { ...sharedControls.x_axis_time_format, default: 'smart_date', description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Temporal], + ), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'x_axis_number_format', + config: { + ...sharedControls.x_axis_number_format, + default: '~g', + mapStateToProps: undefined, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Numeric], + ), }, }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx index 8ab2b6438094..3eb0f814f931 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx @@ -17,8 +17,15 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; -import { ensureIsArray, JsonArray } from '@superset-ui/core'; import { + ensureIsArray, + getColumnLabel, + JsonArray, + QueryFormColumn, +} from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; +import { + checkColumnType, ControlPanelConfig, ControlPanelsContainerProps, ControlSetRow, @@ -154,6 +161,13 @@ function createAxisControl(axis: 'x' | 'y'): ControlSetRow[] { Boolean(controls?.orientation.value === OrientationType.Vertical); const isHorizontal = (controls: ControlStateMapping) => Boolean(controls?.orientation.value === OrientationType.Horizontal); + const isNumericXAxis = (controls: ControlStateMapping) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Numeric], + ); + return [ [ { @@ -163,7 +177,23 @@ function createAxisControl(axis: 'x' | 'y'): ControlSetRow[] { default: 'smart_date', description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, visibility: ({ controls }: ControlPanelsContainerProps) => - isXAxis ? isVertical(controls) : isHorizontal(controls), + (isXAxis ? isVertical(controls) : isHorizontal(controls)) && + !isNumericXAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'x_axis_number_format', + config: { + ...sharedControls.x_axis_number_format, + default: '~g', + mapStateToProps: undefined, + visibility: ({ controls }: ControlPanelsContainerProps) => + (isXAxis ? isVertical(controls) : isHorizontal(controls)) && + isNumericXAxis(controls), disableStash: true, resetOnHide: false, }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx index 5d2ca46e3f3d..ad1ad61e05ff 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx @@ -17,7 +17,10 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; +import { getColumnLabel, QueryFormColumn } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; import { + checkColumnType, ControlPanelConfig, ControlPanelsContainerProps, ControlSubSectionHeader, @@ -146,6 +149,30 @@ const config: ControlPanelConfig = { ...sharedControls.x_axis_time_format, default: 'smart_date', description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Temporal], + ), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'x_axis_number_format', + config: { + ...sharedControls.x_axis_number_format, + default: '~g', + mapStateToProps: undefined, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Numeric], + ), }, }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx index 97a6114b3677..a955da37d52f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx @@ -17,7 +17,10 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; +import { getColumnLabel, QueryFormColumn } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; import { + checkColumnType, ControlPanelConfig, ControlPanelsContainerProps, ControlSubSectionHeader, @@ -112,53 +115,28 @@ const config: ControlPanelConfig = { ...sharedControls.x_axis_time_format, default: 'smart_date', description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, - visibility: ({ controls }: ControlPanelsContainerProps) => { - // check if x axis is a time column - const xAxisColumn = controls?.x_axis?.value; - const xAxisOptions = controls?.x_axis?.options; - - if (!xAxisColumn || !Array.isArray(xAxisOptions)) { - return false; - } - - const xAxisType = xAxisOptions.find( - option => option.column_name === xAxisColumn, - )?.type; - - return ( - typeof xAxisType === 'string' && - xAxisType.toUpperCase().includes('TIME') - ); - }, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Temporal], + ), + disableStash: true, + resetOnHide: false, }, }, { name: 'x_axis_number_format', config: { ...sharedControls.x_axis_number_format, - visibility: ({ controls }: ControlPanelsContainerProps) => { - // check if x axis is a floating-point column - const xAxisColumn = controls?.x_axis?.value; - const xAxisOptions = controls?.x_axis?.options; - - if (!xAxisColumn || !Array.isArray(xAxisOptions)) { - return false; - } - - const xAxisType = xAxisOptions.find( - option => option.column_name === xAxisColumn, - )?.type; - - if (typeof xAxisType !== 'string') { - return false; - } - - const typeUpper = xAxisType.toUpperCase(); - - return ['FLOAT', 'DOUBLE', 'REAL', 'NUMERIC', 'DECIMAL'].some( - t => typeUpper.includes(t), - ); - }, + default: '~g', + mapStateToProps: undefined, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Numeric], + ), }, }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx index c13d89787c39..45128037fca1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx @@ -17,7 +17,10 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; +import { getColumnLabel, QueryFormColumn } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; import { + checkColumnType, ControlPanelConfig, ControlPanelsContainerProps, ControlSubSectionHeader, @@ -111,6 +114,30 @@ const config: ControlPanelConfig = { ...sharedControls.x_axis_time_format, default: 'smart_date', description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Temporal], + ), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'x_axis_number_format', + config: { + ...sharedControls.x_axis_number_format, + default: '~g', + mapStateToProps: undefined, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Numeric], + ), }, }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx index cbb17d7e0b45..87bcb0adc21e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx @@ -17,7 +17,10 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; +import { getColumnLabel, QueryFormColumn } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; import { + checkColumnType, ControlPanelConfig, ControlPanelsContainerProps, ControlSubSectionHeader, @@ -163,6 +166,30 @@ const config: ControlPanelConfig = { ...sharedControls.x_axis_time_format, default: 'smart_date', description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Temporal], + ), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'x_axis_number_format', + config: { + ...sharedControls.x_axis_number_format, + default: '~g', + mapStateToProps: undefined, + visibility: ({ controls }: ControlPanelsContainerProps) => + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Numeric], + ), }, }, ], 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 f7d6fe86c81c..114c374d659f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -659,7 +659,10 @@ export default function transformProps( for (const s of series) { if (s.id) { const columnsArr = labelMap[s.id]; - (s as any).stack = columnsArr[idxSelectedDimension]; + const dimensionValue = columnsArr?.[idxSelectedDimension]; + if (dimensionValue !== undefined) { + (s as any).stack = dimensionValue; + } } } } @@ -682,9 +685,24 @@ export default function transformProps( // For horizontal bar charts, set max/min from calculated data bounds if (shouldCalculateDataBounds) { - // Set max to actual data max to avoid gaps and ensure labels are visible - if (dataMax !== undefined && yAxisMax === undefined) { - yAxisMax = dataMax; + // For stacked charts, clamp against the per-row stacked total to avoid + // clipping bars. Also keep dataMax so that mixed-sign stacks (where + // positive and negative values cancel in the algebraic row sum) cannot + // produce an axis max smaller than the largest individual positive segment. + const stackedTotalMax = Math.max( + ...sortedTotalValues.filter( + (v): v is number => typeof v === 'number' && !Number.isNaN(v), + ), + ); + const effectiveDataMax = stack + ? Math.max(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax) + : dataMax; + if ( + effectiveDataMax !== undefined && + Number.isFinite(effectiveDataMax) && + yAxisMax === undefined + ) { + yAxisMax = effectiveDataMax; } // Set min to actual data min for diverging bars if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) { @@ -711,6 +729,10 @@ export default function transformProps( onLegendScroll, } = hooks; + const addYAxisLabelOffset = + !!yAxisTitle && convertInteger(yAxisTitleMargin) !== 0; + const addXAxisLabelOffset = + !!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0; const legendData = colorByPrimaryAxis && groupBy.length === 0 && series.length > 0 ? (() => { @@ -745,10 +767,6 @@ export default function transformProps( ) .map(entry => entry.name || '') .concat(extractAnnotationLabels(annotationLayers)); - const addYAxisLabelOffset = - !!yAxisTitle && convertInteger(yAxisTitleMargin) !== 0; - const addXAxisLabelOffset = - !!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0; const sortedLegendData = [...legendData].sort((a: string, b: string) => { if (!legendSort) return 0; @@ -824,12 +842,21 @@ export default function transformProps( isHorizontal, ); + // Reduce grid padding for small charts to maximize the drawing area. + // Keep enough top padding so the max label doesn't clip against the cell border. + // Preserve bottom padding when zoomable, since getPadding() reserves space for the dataZoom slider. + if (height < TIMESERIES_CONSTANTS.compactChartHeight) { + padding.top = Math.min(padding.top, 12); + if (!zoomable) { + padding.bottom = Math.min(padding.bottom, 5); + } + } + // When showMaxLabel is true, ECharts may render a label at the axis // boundary that formats identically to the last data-point tick (e.g. // "2005" appears twice with Year grain). Wrap the formatter to suppress // consecutive duplicate labels. - const showMaxLabel = - xAxisType === AxisType.Time && xAxisLabelRotation === 0; + const showMaxLabel = xAxisType === AxisType.Time && xAxisLabelRotation === 0; const deduplicatedFormatter = showMaxLabel ? (() => { let lastLabel: string | undefined; @@ -897,14 +924,35 @@ export default function transformProps( ), }; + // Adapt y-axis to chart height: three tiers based on available space. + // >= 100px: full axis with proportional tick count + // 60-99px: show only min/max boundary labels (splitNumber=1), hide lines/ticks + // < 60px: hide all axis decorations, show line only + const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight; + const isMicroChart = height < TIMESERIES_CONSTANTS.microChartHeight; + const yAxisSplitNumber = isMicroChart + ? undefined + : isSmallChart + ? 1 + : Math.max( + 3, + Math.floor(height / TIMESERIES_CONSTANTS.yAxisPixelsPerTick), + ); + let yAxis: any = { ...defaultYAxis, type: logAxis ? AxisType.Log : AxisType.Value, + ...(yAxisSplitNumber !== undefined && { splitNumber: yAxisSplitNumber }), min: yAxisMin, max: yAxisMax, - minorTick: { show: minorTicks }, - minorSplitLine: { show: minorSplitLine }, + minorTick: { show: isSmallChart ? false : minorTicks }, + minorSplitLine: { show: isSmallChart ? false : minorSplitLine }, + splitLine: { show: !isSmallChart }, axisLabel: { + show: !isMicroChart, + showMinLabel: !isMicroChart, + showMaxLabel: !isMicroChart, + hideOverlap: true, formatter: getYAxisFormatter( metrics, forcePercentFormatter, @@ -913,8 +961,9 @@ export default function transformProps( yAxisFormat, ), }, + axisTick: { show: !isSmallChart }, scale: truncateYAxis, - name: yAxisTitle, + name: isSmallChart ? undefined : yAxisTitle, nameGap: convertInteger(yAxisTitleMargin), nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end', }; @@ -1066,7 +1115,8 @@ export default function transformProps( ...getLegendProps( effectiveLegendType, legendOrientation, - showLegend, + // Hide legend on compact charts — not enough vertical space + isSmallChart ? false : showLegend, theme, zoomable, legendState, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts index 76de92178684..d1169f8a27db 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts @@ -47,6 +47,11 @@ export const TIMESERIES_CONSTANTS = { extraControlsOffset: 22, // Min right padding (px) for horizontal bar charts to ensure value labels are fully visible horizontalBarLabelRightPadding: 70, + // Height thresholds (px) for responsive y-axis behavior + compactChartHeight: 100, + microChartHeight: 60, + // One y-axis tick per this many pixels of chart height + yAxisPixelsPerTick: 80, }; export enum OpacityEnum { diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Area/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Area/controlPanel.test.ts new file mode 100644 index 000000000000..20a36341694f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Area/controlPanel.test.ts @@ -0,0 +1,115 @@ +/** + * 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 { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; +import { GenericDataType } from '@apache-superset/core/common'; +import controlPanel from '../../../src/Timeseries/Area/controlPanel'; + +const config = controlPanel; + +const getControl = (controlName: string) => { + for (const section of config.controlPanelSections) { + if (section && section.controlSetRows) { + for (const row of section.controlSetRows) { + for (const control of row) { + if ( + typeof control === 'object' && + control !== null && + 'name' in control && + control.name === controlName + ) { + return control; + } + } + } + } + } + + return null; +}; + +const mockControls = ( + xAxisColumn: string | null, + typeGeneric: GenericDataType | null, +): ControlPanelsContainerProps => { + const columns = + xAxisColumn && typeGeneric !== null + ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] + : []; + + return { + controls: { + // @ts-expect-error + x_axis: { + value: xAxisColumn, + }, + // @ts-expect-error + datasource: { + datasource: { columns }, + }, + }, + }; +}; + +const timeFormatControl: any = getControl('x_axis_time_format'); +const numberFormatControl: any = getControl('x_axis_number_format'); + +test('should include x_axis_time_format control', () => { + expect(timeFormatControl).toBeDefined(); + expect(timeFormatControl.config.default).toBe('smart_date'); +}); + +test('should include x_axis_number_format control', () => { + expect(numberFormatControl).toBeDefined(); + expect(numberFormatControl.config.default).toBe('~g'); +}); + +test('x_axis_time_format should be visible for temporal columns', () => { + const visibilityFn = timeFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('date', GenericDataType.Temporal))).toBe( + true, + ); +}); + +test('x_axis_time_format should be hidden for numeric columns', () => { + const visibilityFn = timeFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('year', GenericDataType.Numeric))).toBe( + false, + ); +}); + +test('x_axis_number_format should be visible for numeric columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('year', GenericDataType.Numeric))).toBe( + true, + ); +}); + +test('x_axis_number_format should be hidden for temporal columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('date', GenericDataType.Temporal))).toBe( + false, + ); +}); + +test('x_axis_number_format should be hidden for string columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('name', GenericDataType.String))).toBe( + false, + ); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts index 2b56d6ef0ad6..01bb1db740c5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts @@ -16,11 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; +import { GenericDataType } from '@apache-superset/core/common'; import controlPanel from '../../../src/Timeseries/Regular/Bar/controlPanel'; import { StackControlOptionsWithoutStream, StackControlsValue, } from '../../../src/constants'; +import { OrientationType } from '../../../src/Timeseries/types'; const config = controlPanel; @@ -218,3 +221,74 @@ test('should preserve stack value when formData does not have stack property', ( expect(result).not.toHaveProperty('stack'); }); + +// x_axis_number_format visibility tests + +const mockBarControls = ( + xAxisColumn: string | null, + typeGeneric: GenericDataType | null, + orientation: string = OrientationType.Vertical, +): ControlPanelsContainerProps => { + const columns = + xAxisColumn && typeGeneric !== null + ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] + : []; + + return { + controls: { + // @ts-expect-error + x_axis: { + value: xAxisColumn, + }, + // @ts-expect-error + orientation: { + value: orientation, + }, + // @ts-expect-error + datasource: { + datasource: { columns }, + }, + }, + }; +}; + +const numberFormatControl: any = getControl('x_axis_number_format'); +const timeFormatControl: any = getControl('x_axis_time_format'); + +test('should include x_axis_number_format control in the panel', () => { + expect(numberFormatControl).toBeDefined(); +}); + +test('x_axis_number_format should be visible for numeric columns in vertical orientation', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockBarControls('year', GenericDataType.Numeric))).toBe( + true, + ); + expect(visibilityFn(mockBarControls('price', GenericDataType.Numeric))).toBe( + true, + ); +}); + +test('x_axis_number_format should be hidden for time columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockBarControls('date', GenericDataType.Temporal))).toBe( + false, + ); +}); + +test('x_axis_number_format should be hidden for non-numeric columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockBarControls('name', GenericDataType.String))).toBe( + false, + ); + expect(visibilityFn(mockBarControls('flag', GenericDataType.Boolean))).toBe( + false, + ); +}); + +test('x_axis_time_format should be hidden for numeric columns', () => { + const visibilityFn = timeFormatControl?.config?.visibility; + expect(visibilityFn(mockBarControls('year', GenericDataType.Numeric))).toBe( + false, + ); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts index 4d87320849f4..80e434294c13 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts @@ -24,6 +24,7 @@ import { } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { supersetTheme } from '@apache-superset/core/theme'; +import { StackControlsValue } from '../../../src/constants'; import type { GridComponentOption, LegendComponentOption, @@ -727,6 +728,97 @@ describe('Bar Chart X-axis Time Formatting', () => { }); }); + describe('Horizontal stacked bar chart axis bounds', () => { + // Dataset where each series max = 4 but stacked total max = 8 + const stackedData: ChartDataResponseResult[] = [ + createTestQueryData( + [ + { team: 'Team A', High: 2, Low: 2, Medium: 4 }, + { team: 'Team B', High: null, Low: null, Medium: 3 }, + { team: 'Team C', High: null, Low: null, Medium: 1 }, + ], + { + colnames: ['team', 'High', 'Low', 'Medium'], + coltypes: [ + GenericDataType.String, + GenericDataType.Numeric, + GenericDataType.Numeric, + GenericDataType.Numeric, + ], + }, + ), + ]; + + const horizontalStackedFormData: EchartsTimeseriesFormData = { + ...(baseFormData as EchartsTimeseriesFormData), + x_axis: 'team', + metric: ['High', 'Low', 'Medium'], + groupby: [], + orientation: OrientationType.Horizontal, + seriesType: EchartsTimeseriesSeriesType.Bar, + stack: StackControlsValue.Stack, + truncateYAxis: true, + }; + + test('xAxis.max uses stacked total, not individual series max', () => { + // Individual series max = 4 (Medium), stacked total for Team A = 8 + // Without the fix, xAxis.max would be 4, clipping bars and duplicating labels + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsTimeseriesFormData, + EchartsTimeseriesChartProps + >({ + defaultFormData: horizontalStackedFormData, + defaultVizType: 'echarts_timeseries_bar', + defaultQueriesData: stackedData, + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as any; + + // xAxis.max must be >= stacked total (8), not capped at individual series max (4) + expect(xAxis.max).toBeGreaterThanOrEqual(8); + }); + + test('xAxis.max is not set to individual series max when stacking', () => { + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsTimeseriesFormData, + EchartsTimeseriesChartProps + >({ + defaultFormData: horizontalStackedFormData, + defaultVizType: 'echarts_timeseries_bar', + defaultQueriesData: stackedData, + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as any; + + // 4 is the individual series max — the axis should not be clipped there + expect(xAxis.max).not.toBe(4); + }); + + test('non-stacked horizontal bar chart still uses individual series max', () => { + const nonStackedFormData: EchartsTimeseriesFormData = { + ...horizontalStackedFormData, + stack: null, + }; + + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsTimeseriesFormData, + EchartsTimeseriesChartProps + >({ + defaultFormData: nonStackedFormData, + defaultVizType: 'echarts_timeseries_bar', + defaultQueriesData: stackedData, + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as any; + + // Without stacking, xAxis.max should be based on individual series values + expect(xAxis.max).toBe(4); + }); + }); + describe('Legend layout regressions', () => { const getBottomLegendLayout = ( chartWidth: number, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Line/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Line/controlPanel.test.ts new file mode 100644 index 000000000000..4183d1dba7e9 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Line/controlPanel.test.ts @@ -0,0 +1,115 @@ +/** + * 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 { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; +import { GenericDataType } from '@apache-superset/core/common'; +import controlPanel from '../../../src/Timeseries/Regular/Line/controlPanel'; + +const config = controlPanel; + +const getControl = (controlName: string) => { + for (const section of config.controlPanelSections) { + if (section && section.controlSetRows) { + for (const row of section.controlSetRows) { + for (const control of row) { + if ( + typeof control === 'object' && + control !== null && + 'name' in control && + control.name === controlName + ) { + return control; + } + } + } + } + } + + return null; +}; + +const mockControls = ( + xAxisColumn: string | null, + typeGeneric: GenericDataType | null, +): ControlPanelsContainerProps => { + const columns = + xAxisColumn && typeGeneric !== null + ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] + : []; + + return { + controls: { + // @ts-expect-error + x_axis: { + value: xAxisColumn, + }, + // @ts-expect-error + datasource: { + datasource: { columns }, + }, + }, + }; +}; + +const timeFormatControl: any = getControl('x_axis_time_format'); +const numberFormatControl: any = getControl('x_axis_number_format'); + +test('should include x_axis_time_format control', () => { + expect(timeFormatControl).toBeDefined(); + expect(timeFormatControl.config.default).toBe('smart_date'); +}); + +test('should include x_axis_number_format control', () => { + expect(numberFormatControl).toBeDefined(); + expect(numberFormatControl.config.default).toBe('~g'); +}); + +test('x_axis_time_format should be visible for temporal columns', () => { + const visibilityFn = timeFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('date', GenericDataType.Temporal))).toBe( + true, + ); +}); + +test('x_axis_time_format should be hidden for numeric columns', () => { + const visibilityFn = timeFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('year', GenericDataType.Numeric))).toBe( + false, + ); +}); + +test('x_axis_number_format should be visible for numeric columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('year', GenericDataType.Numeric))).toBe( + true, + ); +}); + +test('x_axis_number_format should be hidden for temporal columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('date', GenericDataType.Temporal))).toBe( + false, + ); +}); + +test('x_axis_number_format should be hidden for string columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('name', GenericDataType.String))).toBe( + false, + ); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts index 4c560ea9ff36..943badfe02f6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts @@ -17,6 +17,7 @@ * under the License. */ import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; +import { GenericDataType } from '@apache-superset/core/common'; import controlPanel from '../../../src/Timeseries/Regular/Scatter/controlPanel'; const config = controlPanel; @@ -44,18 +45,22 @@ const getControl = (controlName: string) => { const mockControls = ( xAxisColumn: string | null, - xAxisType: string | null, + typeGeneric: GenericDataType | null, ): ControlPanelsContainerProps => { - const options = xAxisType - ? [{ column_name: xAxisColumn, type: xAxisType }] - : []; + const columns = + xAxisColumn && typeGeneric !== null + ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] + : []; return { controls: { // @ts-expect-error x_axis: { value: xAxisColumn, - options: options, + }, + // @ts-expect-error + datasource: { + datasource: { columns }, }, }, }; @@ -85,26 +90,21 @@ test('scatter chart control panel should have visibility function for x_axis_tim const isTimeVisible = ( xAxisColumn: string | null, - xAxisType: string | null, + xAxisType: GenericDataType | null, ): boolean => { const props = mockControls(xAxisColumn, xAxisType); const visibilityFn = timeFormatControl?.config?.visibility; return visibilityFn ? visibilityFn(props) : false; }; -test('x_axis_time_format control should be visible for any data types include TIME', () => { - expect(isTimeVisible('time_column', 'TIME')).toBe(true); - expect(isTimeVisible('time_column', 'TIME WITH TIME ZONE')).toBe(true); - expect(isTimeVisible('time_column', 'TIMESTAMP WITH TIME ZONE')).toBe(true); - expect(isTimeVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe( - true, - ); +test('x_axis_time_format control should be visible for temporal data types', () => { + expect(isTimeVisible('time_column', GenericDataType.Temporal)).toBe(true); }); -test('x_axis_time_format control should be hidden for data types that do NOT include TIME', () => { - expect(isTimeVisible('null', 'null')).toBe(false); +test('x_axis_time_format control should be hidden for non-temporal data types', () => { expect(isTimeVisible(null, null)).toBe(false); - expect(isTimeVisible('float_column', 'FLOAT')).toBe(false); + expect(isTimeVisible('float_column', GenericDataType.Numeric)).toBe(false); + expect(isTimeVisible('name_column', GenericDataType.String)).toBe(false); }); // tests for x_axis_number_format control @@ -117,7 +117,7 @@ test('scatter chart control panel should include x_axis_number_format control in test('scatter chart control panel should have correct default value for x_axis_number_format', () => { expect(numberFormatControl).toBeDefined(); expect(numberFormatControl.config).toBeDefined(); - expect(numberFormatControl.config.default).toBe('SMART_NUMBER'); + expect(numberFormatControl.config.default).toBe('~g'); }); test('scatter chart control panel should have visibility function for x_axis_number_format', () => { @@ -131,26 +131,20 @@ test('scatter chart control panel should have visibility function for x_axis_num const isNumberVisible = ( xAxisColumn: string | null, - xAxisType: string | null, + xAxisType: GenericDataType | null, ): boolean => { const props = mockControls(xAxisColumn, xAxisType); const visibilityFn = numberFormatControl?.config?.visibility; return visibilityFn ? visibilityFn(props) : false; }; -test('x_axis_number_format control should be visible for any floating-point data types', () => { - expect(isNumberVisible('float_column', 'FLOAT')).toBe(true); - expect(isNumberVisible('double_column', 'DOUBLE')).toBe(true); - expect(isNumberVisible('real_column', 'REAL')).toBe(true); - expect(isNumberVisible('numeric_column', 'NUMERIC')).toBe(true); - expect(isNumberVisible('decimal_column', 'DECIMAL')).toBe(true); +test('x_axis_number_format control should be visible for numeric data types', () => { + expect(isNumberVisible('float_column', GenericDataType.Numeric)).toBe(true); + expect(isNumberVisible('int_column', GenericDataType.Numeric)).toBe(true); }); -test('x_axis_number_format control should be hidden for any non-floating-point data types', () => { - expect(isNumberVisible('string_column', 'VARCHAR')).toBe(false); - expect(isNumberVisible('null', 'null')).toBe(false); +test('x_axis_number_format control should be hidden for non-numeric data types', () => { + expect(isNumberVisible('string_column', GenericDataType.String)).toBe(false); expect(isNumberVisible(null, null)).toBe(false); - expect(isNumberVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe( - false, - ); + expect(isNumberVisible('time_column', GenericDataType.Temporal)).toBe(false); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/SmoothLine/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/SmoothLine/controlPanel.test.ts new file mode 100644 index 000000000000..1c1a634db3d2 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/SmoothLine/controlPanel.test.ts @@ -0,0 +1,101 @@ +/** + * 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 { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; +import { GenericDataType } from '@apache-superset/core/common'; +import controlPanel from '../../../src/Timeseries/Regular/SmoothLine/controlPanel'; + +const config = controlPanel; + +const getControl = (controlName: string) => { + for (const section of config.controlPanelSections) { + if (section && section.controlSetRows) { + for (const row of section.controlSetRows) { + for (const control of row) { + if ( + typeof control === 'object' && + control !== null && + 'name' in control && + control.name === controlName + ) { + return control; + } + } + } + } + } + + return null; +}; + +const mockControls = ( + xAxisColumn: string | null, + typeGeneric: GenericDataType | null, +): ControlPanelsContainerProps => { + const columns = + xAxisColumn && typeGeneric !== null + ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] + : []; + + return { + controls: { + // @ts-expect-error + x_axis: { + value: xAxisColumn, + }, + // @ts-expect-error + datasource: { + datasource: { columns }, + }, + }, + }; +}; + +const timeFormatControl: any = getControl('x_axis_time_format'); +const numberFormatControl: any = getControl('x_axis_number_format'); + +test('should include x_axis_time_format control', () => { + expect(timeFormatControl).toBeDefined(); + expect(timeFormatControl.config.default).toBe('smart_date'); +}); + +test('should include x_axis_number_format control', () => { + expect(numberFormatControl).toBeDefined(); + expect(numberFormatControl.config.default).toBe('~g'); +}); + +test('x_axis_number_format should be visible for numeric columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('year', GenericDataType.Numeric))).toBe( + true, + ); +}); + +test('x_axis_number_format should be hidden for temporal columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('date', GenericDataType.Temporal))).toBe( + false, + ); +}); + +test('x_axis_number_format should be hidden for string columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('name', GenericDataType.String))).toBe( + false, + ); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Step/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Step/controlPanel.test.ts new file mode 100644 index 000000000000..dcd36ebacd29 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Step/controlPanel.test.ts @@ -0,0 +1,115 @@ +/** + * 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 { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; +import { GenericDataType } from '@apache-superset/core/common'; +import controlPanel from '../../../src/Timeseries/Step/controlPanel'; + +const config = controlPanel; + +const getControl = (controlName: string) => { + for (const section of config.controlPanelSections) { + if (section && section.controlSetRows) { + for (const row of section.controlSetRows) { + for (const control of row) { + if ( + typeof control === 'object' && + control !== null && + 'name' in control && + control.name === controlName + ) { + return control; + } + } + } + } + } + + return null; +}; + +const mockControls = ( + xAxisColumn: string | null, + typeGeneric: GenericDataType | null, +): ControlPanelsContainerProps => { + const columns = + xAxisColumn && typeGeneric !== null + ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] + : []; + + return { + controls: { + // @ts-expect-error + x_axis: { + value: xAxisColumn, + }, + // @ts-expect-error + datasource: { + datasource: { columns }, + }, + }, + }; +}; + +const timeFormatControl: any = getControl('x_axis_time_format'); +const numberFormatControl: any = getControl('x_axis_number_format'); + +test('should include x_axis_time_format control', () => { + expect(timeFormatControl).toBeDefined(); + expect(timeFormatControl.config.default).toBe('smart_date'); +}); + +test('should include x_axis_number_format control', () => { + expect(numberFormatControl).toBeDefined(); + expect(numberFormatControl.config.default).toBe('~g'); +}); + +test('x_axis_time_format should be visible for temporal columns', () => { + const visibilityFn = timeFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('date', GenericDataType.Temporal))).toBe( + true, + ); +}); + +test('x_axis_time_format should be hidden for numeric columns', () => { + const visibilityFn = timeFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('year', GenericDataType.Numeric))).toBe( + false, + ); +}); + +test('x_axis_number_format should be visible for numeric columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('year', GenericDataType.Numeric))).toBe( + true, + ); +}); + +test('x_axis_number_format should be hidden for temporal columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('date', GenericDataType.Temporal))).toBe( + false, + ); +}); + +test('x_axis_number_format should be hidden for string columns', () => { + const visibilityFn = numberFormatControl?.config?.visibility; + expect(visibilityFn(mockControls('name', GenericDataType.String))).toBe( + false, + ); +}); 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 c30491601c9e..373cf6a0d540 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 @@ -1338,6 +1338,101 @@ test('should not apply axis bounds calculation when seriesType is not Bar for ho expect(xAxisRaw.max).toBeUndefined(); }); +test('legend is visible on tall charts when enabled by the user', () => { + const chartProps = createTestChartProps({ + height: 400, + formData: { showLegend: true }, + }); + const { legend } = transformProps(chartProps).echartOptions as any; + + expect(legend.show).toBe(true); +}); + +test('legend is hidden on small charts even when enabled by the user', () => { + const chartProps = createTestChartProps({ + height: 80, + formData: { showLegend: true }, + }); + const { legend } = transformProps(chartProps).echartOptions as any; + + expect(legend.show).toBe(false); +}); + +test('y-axis labels remain visible on small charts for scale reference', () => { + const chartProps = createTestChartProps({ height: 80 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(true); +}); + +test('y-axis labels are hidden on micro charts for a sparkline view', () => { + const chartProps = createTestChartProps({ height: 40 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(false); +}); + +test('y-axis tick count scales with chart height', () => { + const short = transformProps(createTestChartProps({ height: 200 })); + const tall = transformProps(createTestChartProps({ height: 500 })); + const shortYAxis = short.echartOptions.yAxis as any; + const tallYAxis = tall.echartOptions.yAxis as any; + + expect(tallYAxis.splitNumber).toBeGreaterThan(shortYAxis.splitNumber); +}); + +test('small chart y-axis uses splitNumber=1 to show only boundary labels', () => { + const chartProps = createTestChartProps({ height: 80 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.splitNumber).toBe(1); +}); + +test('zoomable small chart preserves bottom padding for the dataZoom slider', () => { + const chartProps = createTestChartProps({ + height: 80, + formData: { zoomable: true }, + }); + const result = transformProps(chartProps); + const grid = result.echartOptions.grid as any; + + expect(grid.bottom).toBeGreaterThan(5); +}); + +test('boundary: height at exactly 100px uses full axis behavior', () => { + const chartProps = createTestChartProps({ height: 100 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(true); + expect(yAxis.splitNumber).toBeGreaterThanOrEqual(3); +}); + +test('boundary: height at 99px triggers small chart behavior', () => { + const chartProps = createTestChartProps({ + height: 99, + formData: { showLegend: true }, + }); + const { yAxis, legend } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.splitNumber).toBe(1); + expect(legend.show).toBe(false); +}); + +test('boundary: height at exactly 60px shows labels but uses compact axis', () => { + const chartProps = createTestChartProps({ height: 60 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(true); + expect(yAxis.splitNumber).toBe(1); +}); + +test('boundary: height at 59px triggers micro chart behavior', () => { + const chartProps = createTestChartProps({ height: 59 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(false); +}); + test('x-axis formatter deduplicates consecutive identical labels for coarse time grains', () => { const yearData = [ { __timestamp: Date.UTC(2003, 0, 1), sales: 100 }, diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx index cbf7e1619df0..be4fca29d004 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx @@ -269,6 +269,11 @@ function sortHierarchicalObject( return result; } +function convertToNumberIfNumeric(value: string): string | number { + const n = Number(value); + return value.trim() !== '' && !Number.isNaN(n) ? n : value; +} + function convertToArray( obj: Map, rowEnabled: boolean | undefined, @@ -868,7 +873,7 @@ export class TableRenderer extends Component< ); }; const headerCellFormattedValue = - dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx]; + dateFormatters?.[attrName]?.(convertToNumberIfNumeric(colKey[attrIdx])) ?? colKey[attrIdx]; const { backgroundColor, color } = getCellColor( [attrName], headerCellFormattedValue, @@ -1111,7 +1116,7 @@ export class TableRenderer extends Component< : null; const headerCellFormattedValue = - dateFormatters?.[rowAttrs[i]]?.(r) ?? r; + dateFormatters?.[rowAttrs[i]]?.(convertToNumberIfNumeric(r)) ?? r; const { backgroundColor, color } = getCellColor( [rowAttrs[i]], diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx index 4810225d3bff..6250ef66a444 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx @@ -1050,3 +1050,99 @@ test('renderTableRow uses active header surface for adaptive contrast', () => { ), }); }); + +function makeColPivotSettings( + value: string, +): Parameters[2] { + return { + rowAttrs: [], + colAttrs: ['event_time'], + colKeys: [[value]], + visibleColKeys: [[value]], + colAttrSpans: [[1]], + rowTotals: false, + colSubtotalDisplay: { + enabled: false, + displayOnTop: false, + hideOnExpand: false, + }, + maxColVisible: 1, + pivotData: {}, + namesMapping: {}, + allowRenderHtml: false, + } as unknown as Parameters[2]; +} + +function makeRowPivotSettings(): Parameters< + TableRenderer['renderTableRow'] +>[2] { + const aggregator = { + value: jest.fn().mockReturnValue(1), + format: jest.fn().mockReturnValue('1'), + isSubtotal: false, + }; + return { + rowAttrs: ['event_time'], + colAttrs: [], + rowAttrSpans: [[1]], + visibleColKeys: [[]], + pivotData: { getAggregator: jest.fn().mockReturnValue(aggregator) }, + rowTotals: false, + rowSubtotalDisplay: { + enabled: false, + displayOnTop: false, + hideOnExpand: false, + }, + arrowExpanded: null, + arrowCollapsed: null, + cellCallbacks: {}, + rowTotalCallbacks: {}, + namesMapping: {}, + allowRenderHtml: false, + } as unknown as Parameters[2]; +} + +test.each([ + ['numeric timestamp string', '1700000000000', 1700000000000], + ['non-numeric date string', 'Dec. 16 2020', 'Dec. 16 2020'], + ['ISO timestamp string', '2024-01-15T00:00:00Z', '2024-01-15T00:00:00Z'], +])( + 'col header date formatter receives correct value for %s', + (_, input, expected) => { + const formatter = jest.fn().mockReturnValue('formatted'); + tableRenderer = new TableRenderer({ + ...mockProps, + cols: ['event_time'], + tableOptions: { + ...mockProps.tableOptions, + dateFormatters: { event_time: formatter }, + }, + }); + tableRenderer.renderColHeaderRow( + 'event_time', + 0, + makeColPivotSettings(input), + ); + expect(formatter).toHaveBeenCalledWith(expected); + }, +); + +test.each([ + ['numeric timestamp string', '1700000000000', 1700000000000], + ['non-numeric date string', 'Dec. 16 2020', 'Dec. 16 2020'], +])( + 'row header date formatter receives correct value for %s', + (_, input, expected) => { + const formatter = jest.fn().mockReturnValue('formatted'); + tableRenderer = new TableRenderer({ + ...mockProps, + rows: ['event_time'], + tableOptions: { + ...mockProps.tableOptions, + dateFormatters: { event_time: formatter }, + }, + }); + tableRenderer.renderTableRow([input], 0, makeRowPivotSettings()); + expect(formatter).toHaveBeenCalledWith(expected); + }, +); diff --git a/superset-frontend/scripts/check-custom-rules.js b/superset-frontend/scripts/check-custom-rules.js index a209d51e15c4..ebd9d0a1f83f 100755 --- a/superset-frontend/scripts/check-custom-rules.js +++ b/superset-frontend/scripts/check-custom-rules.js @@ -651,7 +651,7 @@ function main() { } // eslint-disable-next-line no-console - console.log(`Checking ${files.length} files for Superset custom rules...\\n`); + console.log(`Checking ${files.length} files for Superset custom rules...\n`); files.forEach(file => { // Resolve the file path @@ -664,7 +664,7 @@ function main() { }); // eslint-disable-next-line no-console - console.log(`\\n${errorCount} errors, ${warningCount} warnings`); + console.log(`\n${errorCount} errors, ${warningCount} warnings`); if (errorCount > 0) { process.exit(1); diff --git a/superset-frontend/src/components/AlteredSliceTag/utils/index.ts b/superset-frontend/src/components/AlteredSliceTag/utils/index.ts index 482a43326212..9fa9830e7852 100644 --- a/superset-frontend/src/components/AlteredSliceTag/utils/index.ts +++ b/superset-frontend/src/components/AlteredSliceTag/utils/index.ts @@ -52,7 +52,7 @@ export const formatValueHandler = ( v.comparator && v.comparator.constructor === Array ? `[${v.comparator.join(', ')}]` : v.comparator; - return `${v.subject} ${v.operator} ${filterVal}`; + return filterVal ? `${v.subject} ${v.operator} ${filterVal}` : `${v.subject} ${v.operator}`; }) .join(', '); } diff --git a/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts b/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts index feddddeb0a1f..1ceee92c589a 100644 --- a/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts +++ b/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts @@ -258,6 +258,33 @@ describe('formatValueHandler', () => { expect(result).toEqual(expected); }); + + test('formats unary filter', () => { + const filters = [ + { + expressionType: 'SIMPLE', + operator: 'IS NULL', + subject: 'a', + }, + { + clause: 'WHERE', + comparator: ['hu', 'ho', 'ha'], + expressionType: 'SIMPLE', + operator: 'NOT IN', + subject: 'b', + }, + ]; + const key = 'adhoc_filters'; + + const expected = 'a IS NULL, b NOT IN [hu, ho, ha]'; + const formattedValue: string | number = formatValueHandler( + filters, + key, + controlsMap, + ); + + expect(formattedValue).toEqual(expected); + }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx index b723c13d5703..02c2d11a22b0 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx @@ -274,7 +274,11 @@ test('When menu item is clicked, call onSelection with clicked column and drill test('matrixify_mode_rows enabled should not render component', () => { const { container } = renderSubmenu({ - formData: { ...defaultFormData, matrixify_enable: true, matrixify_mode_rows: 'metrics' }, + formData: { + ...defaultFormData, + matrixify_enable: true, + matrixify_mode_rows: 'metrics', + }, }); expect(container).toBeEmptyDOMElement(); }); diff --git a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx index d3e34b76484c..c40f62eb9fdb 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx @@ -45,11 +45,19 @@ const DatasourceEditor = AsyncEsmComponent( () => import('../components/DatasourceEditor'), ); +const MODAL_HEIGHT_VH = 90; +const TOP_MARGIN_VH = (100 - MODAL_HEIGHT_VH) / 2; + const StyledDatasourceModal = styled(Modal)` + top: ${TOP_MARGIN_VH}vh; + padding-bottom: 0; + && .ant-modal-content { max-height: none; margin-top: 0; margin-bottom: 0; + min-height: 500px; + min-width: 500px; } && .ant-modal-body { @@ -367,7 +375,9 @@ const DatasourceModal: FunctionComponent = ({ } responsive resizable - resizableConfig={{ defaultSize: { width: 'auto', height: '900px' } }} + resizableConfig={{ + defaultSize: { width: 'auto', height: `${MODAL_HEIGHT_VH}vh` }, + }} draggable > theme.paddingMD}px; } .ant-tabs-content { @@ -2525,18 +2526,20 @@ class DatasourceEditor extends PureComponent< key: TABS_KEYS.SETTINGS, label: t('Settings'), children: ( - - - - {this.renderSettingsFieldset()} - - - - - {this.renderAdvancedFieldset()} - - - +
+ + + + {this.renderSettingsFieldset()} + + + + + {this.renderAdvancedFieldset()} + + + +
), }, ]} diff --git a/superset-frontend/src/core/sqlLab/sqlLab.test.ts b/superset-frontend/src/core/sqlLab/sqlLab.test.ts index e5e7219805ef..5b7356c939b4 100644 --- a/superset-frontend/src/core/sqlLab/sqlLab.test.ts +++ b/superset-frontend/src/core/sqlLab/sqlLab.test.ts @@ -16,10 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { - configureStore, - createListenerMiddleware, -} from '@reduxjs/toolkit'; +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import type { QueryEditor } from 'src/SqlLab/types'; import sqlLabReducer from 'src/SqlLab/reducers/sqlLab'; import { @@ -369,7 +366,10 @@ test('onDidCloseTab fires with Tab on REMOVE_QUERY_EDITOR', async () => { test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => { // Add a second editor so switching back is a real change - mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() }); + mockStore.dispatch({ + type: ADD_QUERY_EDITOR, + queryEditor: makeSecondEditor(), + }); const listener = jest.fn(); const disposable = sqlLab.onDidChangeActiveTab(listener); @@ -418,7 +418,10 @@ test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => { test('editor-scoped listener does not fire for a different editor', () => { // Add a second editor (ADD_QUERY_EDITOR makes it active) - mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() }); + mockStore.dispatch({ + type: ADD_QUERY_EDITOR, + queryEditor: makeSecondEditor(), + }); // Switch back to editor-1 so the predicate captures immutable-1 mockStore.dispatch({ @@ -444,7 +447,10 @@ test('editor-scoped listener does not fire for a different editor', () => { test('editor-scoped predicate filters tab events via queryEditor lookup', () => { // Add a second editor and switch back to editor-1 - mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() }); + mockStore.dispatch({ + type: ADD_QUERY_EDITOR, + queryEditor: makeSecondEditor(), + }); mockStore.dispatch({ type: SET_ACTIVE_QUERY_EDITOR, queryEditor: { id: EDITOR_ID }, @@ -468,7 +474,10 @@ test('editor-scoped predicate filters tab events via queryEditor lookup', () => test('globalPredicate listener fires for a non-active tab', () => { // Add editor-2 and switch to it - mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() }); + mockStore.dispatch({ + type: ADD_QUERY_EDITOR, + queryEditor: makeSecondEditor(), + }); mockStore.dispatch({ type: SET_ACTIVE_QUERY_EDITOR, queryEditor: { id: 'editor-2' }, diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index a706f89b7deb..a507c65318de 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -523,30 +523,33 @@ const DashboardBuilder = () => { ({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
{dropIndicatorProps &&
} - {!isReport && topLevelTabs && !uiConfig.hideTab && !uiConfig.hideNav && ( - } - label={t('Collapse tab content')} - onClick={handleDeleteTopLevelTabs} - />, - ]} - editMode={editMode} - > - - - )} + {!isReport && + topLevelTabs && + !uiConfig.hideTab && + !uiConfig.hideNav && ( + } + label={t('Collapse tab content')} + onClick={handleDeleteTopLevelTabs} + />, + ]} + editMode={editMode} + > + + + )}
), [ diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index b53bfd6fb66a..d472923d1436 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -53,6 +53,7 @@ import { setColorScheme, setDashboardMetadata, } from 'src/dashboard/actions/dashboardState'; +import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo'; import { areObjectsEqual } from 'src/reduxUtils'; import { StandardModal, useModalValidation } from 'src/components/Modal'; import { validateRefreshFrequency } from '../RefreshFrequency'; @@ -143,6 +144,8 @@ const PropertiesModal = ({ >([]); const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); const originalDashboardMetadata = useRef>({}); + const originalCss = useRef(null); + const cssDebounceTimer = useRef | null>(null); const handleErrorResponse = async (response: Response) => { const { error, statusText, message } = await getClientErrorObject(response); @@ -195,6 +198,9 @@ const PropertiesModal = ({ setOwners(owners); setRoles(roles); setCustomCss(css || ''); + if (originalCss.current === null) { + originalCss.current = css || ''; + } setCurrentColorScheme(metadata?.color_scheme); setSelectedThemeId(theme?.id || null); @@ -269,7 +275,19 @@ const PropertiesModal = ({ setRoles(parsedRoles); }; - const handleOnCancel = () => onHide(); + const handleOnCancel = () => { + if (cssDebounceTimer.current) { + clearTimeout(cssDebounceTimer.current); + cssDebounceTimer.current = null; + } + if (originalCss.current !== null) { + dispatch(dashboardInfoChanged({ css: originalCss.current })); + dispatch( + setColorScheme(originalDashboardMetadata.current.color_scheme ?? ''), + ); + } + onHide(); + }; const onColorSchemeChange = ( colorScheme = '', @@ -429,6 +447,14 @@ const PropertiesModal = ({ } }; + // Must be defined before the data-loading effect so it runs first when show + // becomes true, ensuring handleDashboardData sees null and captures original CSS + useEffect(() => { + if (show) { + originalCss.current = null; + } + }, [show]); + useEffect(() => { if (show) { // Reset loading state when modal opens @@ -596,6 +622,32 @@ const PropertiesModal = ({ const isDataReady = !isLoading && dashboardInfo; + // Debounced live CSS preview so changes are reflected on the dashboard + // without clicking Apply. Called only on user edits, not on data load. + const handleCustomCssChange = useCallback( + (css: string) => { + setCustomCss(css); + if (cssDebounceTimer.current) { + clearTimeout(cssDebounceTimer.current); + cssDebounceTimer.current = null; + } + cssDebounceTimer.current = setTimeout(() => { + dispatch(dashboardInfoChanged({ css })); + }, 500); + }, + [dispatch], + ); + + useEffect( + () => () => { + if (cssDebounceTimer.current) { + clearTimeout(cssDebounceTimer.current); + cssDebounceTimer.current = null; + } + }, + [], + ); + // Validate basic section when title changes or data loads useEffect(() => { if (isDataReady) { @@ -722,7 +774,7 @@ const PropertiesModal = ({ showChartTimestamps={showChartTimestamps} onThemeChange={handleThemeChange} onColorSchemeChange={onColorSchemeChange} - onCustomCssChange={setCustomCss} + onCustomCssChange={handleCustomCssChange} onShowChartTimestampsChange={setShowChartTimestamps} addDangerToast={addDangerToast} /> diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx index 466c56ee1ac7..4b797b36345d 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx @@ -407,6 +407,119 @@ describe('ControlPanelsContainer', () => { getChartControlPanelRegistry().remove('line'); }); + test('should stash control value when visibility is false and disableStash is not set', async () => { + getChartControlPanelRegistry().remove('table'); + getChartControlPanelRegistry().registerValue('table', { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [ + { + name: 'x_axis_time_format', + config: { + type: 'SelectControl', + label: t('Time Format'), + default: 'smart_date', + choices: [['smart_date', 'Adaptive Formatting']], + visibility: () => false, + }, + }, + ], + ], + }, + ], + }); + + const props = getDefaultProps(); + props.form_data = { + ...props.form_data, + x_axis_time_format: 'smart_date', + }; + + const { getByTestId } = render( + <> + + + , + { + useRedux: true, + initialState: { + explore: { + form_data: { + ...defaultState.form_data, + x_axis_time_format: 'smart_date', + }, + }, + }, + }, + ); + + await waitFor(() => { + expect(getByTestId('mock-formdata')).not.toHaveTextContent( + 'x_axis_time_format', + ); + }); + }); + + test('should preserve control value when visibility is false and disableStash is true', async () => { + getChartControlPanelRegistry().remove('table'); + getChartControlPanelRegistry().registerValue('table', { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [ + { + name: 'x_axis_time_format', + config: { + type: 'SelectControl', + label: t('Time Format'), + default: 'smart_date', + choices: [['smart_date', 'Adaptive Formatting']], + visibility: () => false, + disableStash: true, + }, + }, + ], + ], + }, + ], + }); + + const props = getDefaultProps(); + props.form_data = { + ...props.form_data, + x_axis_time_format: 'smart_date', + }; + + const { getByTestId } = render( + <> + + + , + { + useRedux: true, + initialState: { + explore: { + form_data: { + ...defaultState.form_data, + x_axis_time_format: 'smart_date', + }, + }, + }, + }, + ); + + await waitFor(() => { + expect(getByTestId('mock-formdata')).toHaveTextContent( + 'x_axis_time_format', + ); + }); + }); + test('should not show Matrixify tab for table chart types', async () => { // Enable Matrixify feature flag mockIsFeatureEnabled.mockImplementation( diff --git a/superset-frontend/src/pages/Chart/index.tsx b/superset-frontend/src/pages/Chart/index.tsx index 3f8d28ce2dc2..46acccad36a4 100644 --- a/superset-frontend/src/pages/Chart/index.tsx +++ b/superset-frontend/src/pages/Chart/index.tsx @@ -85,11 +85,11 @@ const getDashboardPageContext = (pageId?: string | null) => { return getItem(LocalStorageKeys.DashboardExploreContext, {})[pageId] || null; }; -const getDashboardContextFormData = () => { - const dashboardPageId = getUrlParam(URL_PARAMS.dashboardPageId); +const getDashboardContextFormData = (search: string) => { + const dashboardPageId = getUrlParam(URL_PARAMS.dashboardPageId, search); const dashboardContext = getDashboardPageContext(dashboardPageId); if (dashboardContext) { - const sliceId = getUrlParam(URL_PARAMS.sliceId) || 0; + const sliceId = getUrlParam(URL_PARAMS.sliceId, search) || 0; const { colorScheme, labelsColor, @@ -141,7 +141,7 @@ export default function ExplorePage() { fetchGeneration.current += 1; const generation = fetchGeneration.current; const exploreUrlParams = getParsedExploreURLParams(loc); - const dashboardContextFormData = getDashboardContextFormData(); + const dashboardContextFormData = getDashboardContextFormData(loc.search); const isStale = () => generation !== fetchGeneration.current; diff --git a/superset-frontend/src/utils/urlUtils.test.ts b/superset-frontend/src/utils/urlUtils.test.ts index e81d890d8738..d995d539be49 100644 --- a/superset-frontend/src/utils/urlUtils.test.ts +++ b/superset-frontend/src/utils/urlUtils.test.ts @@ -22,7 +22,9 @@ import { parseUrl, toQueryString, getDashboardUrlParams, + getUrlParam, } from './urlUtils'; +import { URL_PARAMS } from '../constants'; test('isUrlExternal', () => { expect(isUrlExternal('http://google.com')).toBeTruthy(); @@ -145,3 +147,45 @@ test('getDashboardUrlParams should exclude multiple parameters when provided', ( // Restore original location window.location = originalLocation; }); + +test('getUrlParam reads from window.location.search by default', () => { + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '?dashboard_page_id=from-window' }, + writable: true, + configurable: true, + }); + + expect(getUrlParam(URL_PARAMS.dashboardPageId)).toBe('from-window'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); +}); + +test('getUrlParam uses provided search string instead of window.location.search (Safari race condition fix)', () => { + // Simulate Safari race condition: window.location.search is stale (empty), + // but the correct search string is passed in from React Router's useLocation() + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true, + configurable: true, + }); + + // Without the search override, window.location.search is stale — returns null (the bug) + expect(getUrlParam(URL_PARAMS.dashboardPageId)).toBeNull(); + + // With the search override (the fix), returns the correct value + expect( + getUrlParam(URL_PARAMS.dashboardPageId, '?dashboard_page_id=correct-id'), + ).toBe('correct-id'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); +}); diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index 4b218201144e..dedc20c83302 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -38,22 +38,35 @@ export type UrlParamType = 'string' | 'number' | 'boolean' | 'object' | 'rison'; export type UrlParam = (typeof URL_PARAMS)[keyof typeof URL_PARAMS]; export function getUrlParam( param: UrlParam & { type: 'string' }, + search?: string, ): string | null; export function getUrlParam( param: UrlParam & { type: 'number' }, + search?: string, ): number | null; export function getUrlParam( param: UrlParam & { type: 'boolean' }, + search?: string, ): boolean | null; export function getUrlParam( param: UrlParam & { type: 'object' }, + search?: string, ): object | null; -export function getUrlParam(param: UrlParam & { type: 'rison' }): object | null; +export function getUrlParam( + param: UrlParam & { type: 'rison' }, + search?: string, +): string | object | null; export function getUrlParam( param: UrlParam & { type: 'rison | string' }, + search?: string, ): string | object | null; -export function getUrlParam({ name, type }: UrlParam): unknown { - const urlParam = new URLSearchParams(window.location.search).get(name); +export function getUrlParam( + { name, type }: UrlParam, + search?: string, +): unknown { + const urlParam = new URLSearchParams(search ?? window.location.search).get( + name, + ); switch (type) { case 'number': if (!urlParam) { diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index 3600aac0649d..a7ef5c7da239 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -10,8 +10,8 @@ "license": "Apache-2.0", "dependencies": { "cookie": "^1.1.1", - "hot-shots": "^14.1.1", - "ioredis": "^5.10.0", + "hot-shots": "^14.2.0", + "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "lodash": "^4.17.23", "winston": "^3.19.0", @@ -23,11 +23,11 @@ "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", "@types/lodash": "^4.17.24", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.55.0", + "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.57.0", - "eslint": "^10.0.2", + "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-lodash": "^8.0.0", "globals": "^17.4.0", @@ -748,15 +748,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -773,9 +773,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -786,13 +786,13 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -802,22 +802,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^1.1.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -841,9 +841,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -851,13 +851,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { @@ -1798,9 +1798,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { @@ -1844,20 +1844,20 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1867,9 +1867,186 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.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/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.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/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "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/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "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/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.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/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.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/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.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/eslint-plugin/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/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/eslint-plugin/node_modules/ignore": { @@ -1882,17 +2059,33 @@ "node": ">= 4" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "engines": { @@ -1904,18 +2097,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "engines": { @@ -1926,18 +2119,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1948,9 +2141,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", "engines": { @@ -1961,13 +2154,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -1979,21 +2172,21 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2003,17 +2196,17 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2035,9 +2228,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2061,13 +2254,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -2134,28 +2327,221 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "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": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.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/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.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/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "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/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.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/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.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/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.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/type-utils/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/type-utils/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/type-utils/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" }, "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.0.0" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/types": { @@ -2963,18 +3349,18 @@ } }, "node_modules/eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2983,9 +3369,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2996,7 +3382,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3051,9 +3437,9 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3236,9 +3622,9 @@ } }, "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3633,12 +4019,12 @@ } }, "node_modules/hot-shots": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.1.1.tgz", - "integrity": "sha512-UrhMjtZPZVqgzHdXUCHJYrul4dAYueaaIJcscvIXHI6uZB3yoHFArH6jFnM1yAZ8NfSf58d8mgQPJyBFOuTa4Q==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.2.0.tgz", + "integrity": "sha512-MiEPF/VsmzY2MnfjxDNTEwrDUa+51WeYugLZkzhEqNsWoY0TgwWH3FIDT7QKzOq6K79A5w3tIBxcdyFWeJ6jbg==", "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "optionalDependencies": { "unix-dgram": "2.x" @@ -3713,9 +4099,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ioredis": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", - "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "license": "MIT", "dependencies": { "@ioredis/commands": "1.5.1", @@ -5618,9 +6004,9 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6124,9 +6510,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6172,9 +6558,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -6391,6 +6777,35 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", @@ -6416,6 +6831,41 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.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.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -7238,14 +7688,14 @@ "dev": true }, "@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "requires": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" }, "dependencies": { "balanced-match": { @@ -7255,38 +7705,38 @@ "dev": true }, "brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "requires": { "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "requires": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" } } } }, "@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, "requires": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^1.1.1" } }, "@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "requires": { "@types/json-schema": "^7.0.15" @@ -7299,18 +7749,18 @@ "dev": true }, "@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true }, "@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "requires": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, @@ -8088,9 +8538,9 @@ "dev": true }, "@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "requires": { "undici-types": "~7.18.0" @@ -8132,100 +8582,203 @@ "dev": true }, "@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "dependencies": { + "@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + } + }, + "@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + } + }, + "@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "requires": {} + }, + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.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": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "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 + }, "ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } } } }, "@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "dependencies": { "@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "requires": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" } }, "@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "requires": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" } }, "@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "requires": {} }, "@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "requires": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "requires": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, @@ -8236,9 +8789,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -8251,12 +8804,12 @@ "dev": true }, "minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "requires": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" } } } @@ -8290,16 +8843,121 @@ "requires": {} }, "@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "requires": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" + }, + "dependencies": { + "@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + } + }, + "@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + } + }, + "@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "requires": {} + }, + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.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": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "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 + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + } } }, "@typescript-eslint/types": { @@ -8865,17 +9523,17 @@ "dev": true }, "eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -8884,9 +9542,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -8897,7 +9555,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -9017,9 +9675,9 @@ } }, "eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "requires": { "@types/esrecurse": "^4.3.1", @@ -9035,9 +9693,9 @@ "dev": true }, "espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "requires": { "acorn": "^8.16.0", @@ -9318,9 +9976,9 @@ } }, "hot-shots": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.1.1.tgz", - "integrity": "sha512-UrhMjtZPZVqgzHdXUCHJYrul4dAYueaaIJcscvIXHI6uZB3yoHFArH6jFnM1yAZ8NfSf58d8mgQPJyBFOuTa4Q==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.2.0.tgz", + "integrity": "sha512-MiEPF/VsmzY2MnfjxDNTEwrDUa+51WeYugLZkzhEqNsWoY0TgwWH3FIDT7QKzOq6K79A5w3tIBxcdyFWeJ6jbg==", "requires": { "unix-dgram": "2.x" } @@ -9375,9 +10033,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ioredis": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", - "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "requires": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", @@ -10866,9 +11524,9 @@ "dev": true }, "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true }, "pirates": { @@ -11200,9 +11858,9 @@ "requires": {} }, "picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true } } @@ -11234,9 +11892,9 @@ "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==" }, "ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "requires": {} }, @@ -11342,6 +12000,22 @@ "@typescript-eslint/utils": "8.56.1" }, "dependencies": { + "@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + } + }, "@typescript-eslint/parser": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", @@ -11354,6 +12028,25 @@ "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" } + }, + "@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + } + }, + "ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true } } }, diff --git a/superset-websocket/package.json b/superset-websocket/package.json index 6ed60ad10d49..da15e4e6a844 100644 --- a/superset-websocket/package.json +++ b/superset-websocket/package.json @@ -18,8 +18,8 @@ "license": "Apache-2.0", "dependencies": { "cookie": "^1.1.1", - "hot-shots": "^14.1.1", - "ioredis": "^5.10.0", + "hot-shots": "^14.2.0", + "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "lodash": "^4.17.23", "winston": "^3.19.0", @@ -31,11 +31,11 @@ "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", "@types/lodash": "^4.17.24", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.55.0", + "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.57.0", - "eslint": "^10.0.2", + "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-lodash": "^8.0.0", "globals": "^17.4.0", diff --git a/superset-websocket/utils/client-ws-app/package-lock.json b/superset-websocket/utils/client-ws-app/package-lock.json index 965ae119dbee..a095efa37016 100644 --- a/superset-websocket/utils/client-ws-app/package-lock.json +++ b/superset-websocket/utils/client-ws-app/package-lock.json @@ -970,12 +970,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", - "engines": { - "node": ">=16" + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/promise": { @@ -2118,9 +2118,9 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==" + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==" }, "promise": { "version": "7.3.1", diff --git a/superset/common/query_object.py b/superset/common/query_object.py index 3f0d796ebc42..577fc66de1ef 100644 --- a/superset/common/query_object.py +++ b/superset/common/query_object.py @@ -344,7 +344,9 @@ def _sanitize_filters(self) -> None: if clause and self.datasource: try: database = self.datasource.database - processor = get_template_processor(database=database) + processor = get_template_processor( + database=database, table=self.datasource + ) try: clause = processor.process_template(clause, force=True) except TemplateError as ex: diff --git a/superset/datasets/api.py b/superset/datasets/api.py index dbb51b9bf355..845c6fe45794 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -1257,7 +1257,7 @@ def get(self, id_or_uuid: str, **kwargs: Any) -> Response: if parse_boolean_string(request.args.get("include_rendered_sql")): try: - processor = get_template_processor(database=table.database) + processor = get_template_processor(database=table.database, table=table) response["result"] = self.render_dataset_fields( response["result"], processor ) diff --git a/superset/db_engine_specs/crate.py b/superset/db_engine_specs/crate.py index 916f98f3f01f..dec3021f73e8 100644 --- a/superset/db_engine_specs/crate.py +++ b/superset/db_engine_specs/crate.py @@ -37,7 +37,7 @@ class CrateEngineSpec(BaseEngineSpec): "CrateDB is a distributed SQL database for machine data and IoT workloads." ), "logo": "cratedb.svg", - "homepage_url": "https://crate.io/", + "homepage_url": "https://cratedb.com", "categories": [DatabaseCategory.TIME_SERIES, DatabaseCategory.OPEN_SOURCE], "pypi_packages": ["crate", "sqlalchemy-cratedb"], "connection_string": "crate://{host}:{port}", diff --git a/superset/db_engine_specs/presto.py b/superset/db_engine_specs/presto.py index d8109e1a301a..baea622d92eb 100644 --- a/superset/db_engine_specs/presto.py +++ b/superset/db_engine_specs/presto.py @@ -920,6 +920,19 @@ class PrestoEngineSpec(PrestoBaseEngineSpec): ], } + @classmethod + def convert_dttm( + cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None + ) -> str | None: + sqla_type = cls.get_sqla_column_type(target_type) + + if isinstance(sqla_type, types.Date): + return f"DATE '{dttm.date().isoformat()}'" + if isinstance(sqla_type, types.TIMESTAMP): + return f"""TIMESTAMP '{dttm.isoformat(timespec="milliseconds", sep=" ")}'""" + + return None + custom_errors: dict[Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]]] = { COLUMN_DOES_NOT_EXIST_REGEX: ( __( diff --git a/superset/mcp_service/SECURITY.md b/superset/mcp_service/SECURITY.md index e17431d30707..a0825cae90aa 100644 --- a/superset/mcp_service/SECURITY.md +++ b/superset/mcp_service/SECURITY.md @@ -35,9 +35,12 @@ MCP_DEV_USERNAME = "admin" ``` **How it works**: -1. The `@mcp_auth_hook` decorator calls `get_user_from_request()` -2. `get_user_from_request()` reads `MCP_DEV_USERNAME` from config -3. User is queried from database and set as `g.user` +1. The `@mcp_auth_hook` decorator clears any stale `g.user` and calls `get_user_from_request()` +2. `get_user_from_request()` resolves the user in priority order: + - **JWT auth context** (per-request ContextVar from MCP SDK) — safest, prevents stale user impersonation + - **`MCP_DEV_USERNAME`** from config — for development/single-user deployments + - **`g.user` fallback** — for external middleware (e.g., Preset's WorkspaceContextMiddleware) +3. User is queried from database (with roles/groups eagerly loaded) and set as `g.user` 4. All subsequent Superset operations use this user's permissions **Development Use Only**: diff --git a/superset/mcp_service/__main__.py b/superset/mcp_service/__main__.py index 0da7df6655e5..30759c6e8056 100644 --- a/superset/mcp_service/__main__.py +++ b/superset/mcp_service/__main__.py @@ -44,6 +44,36 @@ def secho_to_stderr(*args: Any, **kwargs: Any) -> Any: from superset.mcp_service.app import init_fastmcp_server, mcp +def _add_default_middlewares() -> None: + """Add the standard middleware stack to the MCP instance. + + This ensures all entry points (stdio, streamable-http, etc.) get + the same protection middlewares that the Flask CLI and server.py add. + Order is innermost → outermost (last-added wraps everything). + """ + from superset.mcp_service.middleware import ( + create_response_size_guard_middleware, + GlobalErrorHandlerMiddleware, + LoggingMiddleware, + StructuredContentStripperMiddleware, + ) + + # Response size guard (innermost among these) + if size_guard := create_response_size_guard_middleware(): + mcp.add_middleware(size_guard) + limit = size_guard.token_limit + sys.stderr.write(f"[MCP] Response size guard enabled (token_limit={limit})\n") + + # Logging + mcp.add_middleware(LoggingMiddleware()) + + # Global error handler + mcp.add_middleware(GlobalErrorHandlerMiddleware()) + + # Structured content stripper (must be outermost) + mcp.add_middleware(StructuredContentStripperMiddleware()) + + def main() -> None: """ Run the MCP service in stdio mode with proper output suppression. @@ -97,6 +127,7 @@ def main() -> None: # Initialize the FastMCP server # Disable auth config for stdio mode to avoid Flask app output init_fastmcp_server() + _add_default_middlewares() # Log captured output to stderr for debugging (optional) captured = captured_output.getvalue() @@ -118,6 +149,7 @@ def main() -> None: else: # For other transports, use normal initialization init_fastmcp_server() + _add_default_middlewares() # Run with specified transport if transport == "streamable-http": diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 3fb388f8aafa..086bc7f668be 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -88,11 +88,29 @@ def get_default_instructions(branding: str = "Apache Superset") -> str: - quickstart: Interactive guide for getting started with the MCP service - create_chart_guided: Step-by-step chart creation wizard +IMPORTANT - Using Saved Metrics vs Columns: +When get_dataset_info returns a dataset, it includes both 'columns' and 'metrics'. +- 'columns' are raw database columns (e.g., order_date, product_name, revenue) +- 'metrics' are pre-defined saved metrics with SQL expressions + (e.g., count, total_revenue) + +When building chart configurations +(generate_chart, generate_explore_link, update_chart): +- For raw columns: use {{"name": "col_name", "aggregate": "SUM"}} +- For saved metrics: use {{"name": "metric", "saved_metric": true}} + Do NOT add an aggregate when using saved_metric=true + (it's already defined in the metric). + Do NOT use a saved metric name as if it were a column — it will fail. + +Example: If get_dataset_info returns metrics=[{{"metric_name": "count", ...}}], use: + {{"name": "count", "saved_metric": true}} ← CORRECT + {{"name": "count", "aggregate": "COUNT"}} ← WRONG (count is not a column) + Recommended Workflows: To create a chart: 1. list_datasets -> find a dataset -2. get_dataset_info(id) -> examine columns and metrics +2. get_dataset_info(id) -> examine columns AND metrics (note which names are metrics!) 3. generate_explore_link(dataset_id, config) -> preview interactively 4. generate_chart(dataset_id, config, save_chart=True) -> save permanently @@ -118,6 +136,9 @@ def get_default_instructions(branding: str = "Apache Superset") -> str: - chart_type="xy", kind="bar": Bar chart for category comparison - chart_type="xy", kind="area": Area chart for volume visualization - chart_type="xy", kind="scatter": Scatter plot for correlation analysis +- chart_type="big_number": Big Number display (single metric, header only) +- chart_type="big_number", show_trendline=True, + temporal_column="": Big Number with trendline - chart_type="table": Data table for detailed views - chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table - chart_type="pie": Pie chart for proportional data (set donut=True for donut) diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py index 937329366cbd..b7be643e03c2 100644 --- a/superset/mcp_service/auth.py +++ b/superset/mcp_service/auth.py @@ -189,114 +189,196 @@ def load_user_with_relationships( return query.first() -def get_user_from_request() -> User: +def _resolve_user_from_jwt_context(app: Any) -> User | None: """ - Get the current user for the MCP tool request. + Resolve the current user from the MCP SDK's per-request JWT context. - Priority order: - 1. g.user if already set (by Preset workspace middleware or FastMCP auth) - 2. API key from Authorization header (via FAB SecurityManager) - 3. MCP_DEV_USERNAME from configuration (for development/testing) + Uses FastMCP's ``get_access_token()`` which returns the JWT AccessToken + for the current async task via a ContextVar — safe across concurrent + requests, unlike ``g.user`` which can be stale. + + The username is extracted from token claims using a configurable resolver + (``MCP_USER_RESOLVER`` config) or the default ``default_user_resolver()``. Returns: - User object with roles and groups eagerly loaded + User object with relationships loaded, or None if no JWT context + (i.e. no token present — caller should fall through to next source). Raises: - ValueError: If user cannot be authenticated or found + ValueError: If JWT resolves a username that doesn't exist in the DB + (fail closed — do NOT fall through to weaker auth sources). """ - from flask import current_app + try: + from fastmcp.server.dependencies import get_access_token + except ImportError: + logger.debug("fastmcp.server.dependencies not available, skipping JWT context") + return None - # First check if user is already set by Preset workspace middleware - if hasattr(g, "user") and g.user: - return g.user + access_token = get_access_token() + if access_token is None: + return None - # Try API key authentication via FAB SecurityManager - # Only attempt when in a request context (not for MCP internal operations - # like tool discovery that run with only an application context) - # Use the Flask config key FAB_API_KEY_ENABLED (not the feature flag), - # because the config key controls whether FAB registers the API key - # endpoints and validation logic. The feature flag with the same name - # in DEFAULT_FEATURE_FLAGS only controls the frontend UI visibility. - if current_app.config.get("FAB_API_KEY_ENABLED", False) and has_request_context(): - sm = current_app.appbuilder.sm - # _extract_api_key_from_request is FAB's internal method for reading - # the Bearer token from the Authorization header and matching prefixes. - # Not all FAB versions include this method, so guard with hasattr. - if not hasattr(sm, "_extract_api_key_from_request"): - logger.debug( - "FAB SecurityManager does not have _extract_api_key_from_request; " - "API key authentication is not available in this FAB version" - ) - else: - api_key_string = sm._extract_api_key_from_request() - if api_key_string is not None: - if not hasattr(sm, "validate_api_key"): - logger.warning( - "FAB SecurityManager does not have validate_api_key; " - "cannot validate API key" - ) - raise PermissionError( - "API key validation is not available in this FAB version." - ) - user = sm.validate_api_key(api_key_string) - if user: - # Reload user with all relationships eagerly loaded to avoid - # detached-instance errors during later permission checks. - user_with_rels = load_user_with_relationships( - username=user.username, - ) - if user_with_rels is None: - logger.warning( - "Failed to reload API key user %s with relationships; " - "using original user object which may have lazy-loaded " - "relationships", - user.username, - ) - return user - return user_with_rels - raise PermissionError( - "Invalid or expired API key. " - "Create a new key at /api/v1/security/api_keys/." - ) - - # Fall back to configured username for development/single-user deployments - username = current_app.config.get("MCP_DEV_USERNAME") + # Use configurable resolver or default + from superset.mcp_service.mcp_config import default_user_resolver + + resolver = app.config.get("MCP_USER_RESOLVER", default_user_resolver) + username = resolver(app, access_token) if not username: - auth_enabled = current_app.config.get("MCP_AUTH_ENABLED", False) - jwt_configured = bool( - current_app.config.get("MCP_JWKS_URI") - or current_app.config.get("MCP_JWT_PUBLIC_KEY") - or current_app.config.get("MCP_JWT_SECRET") - ) - details = [] - details.append( - f"g.user was not set by JWT middleware " - f"(MCP_AUTH_ENABLED={auth_enabled}, " - f"JWT keys configured={jwt_configured})" - ) - details.append("MCP_DEV_USERNAME is not configured") - configured_prefixes = current_app.config.get("FAB_API_KEY_PREFIXES", ["sst_"]) - prefix_example = configured_prefixes[0] if configured_prefixes else "sst_" + # Fail closed: JWT is present but identity cannot be determined. + # Do NOT fall through to weaker auth sources. raise ValueError( - "No authenticated user found. Tried:\n" - + "\n".join(f" - {d}" for d in details) - + f"\n\nEither pass a valid API key (Bearer {prefix_example}...), " - "JWT token, or configure MCP_DEV_USERNAME for development." + "JWT context present but no username could be extracted from claims" ) - # Use helper function to load user with all required relationships + # Try username lookup first, then email fallback for OIDC email claims user = load_user_with_relationships(username) - + if not user and "@" in username: + user = load_user_with_relationships(email=username) if not user: + # Fail closed: JWT says this user should exist but they don't. + # Do NOT fall through to MCP_DEV_USERNAME or stale g.user. raise ValueError( - f"User '{username}' not found. " - f"Please create admin user with: superset fab create-admin" + f"JWT authenticated user '{username}' not found in Superset database. " + f"Ensure the user exists before granting MCP access." ) return user +def _resolve_user_from_api_key(app: Any) -> User | None: + """ + Resolve the current user from an API key in the Authorization header. + + Uses FAB SecurityManager's API key validation. Only attempts when + FAB_API_KEY_ENABLED is True and a request context is active. + + Returns: + User object with relationships loaded, or None if no API key present + or API key auth is not enabled/available. + + Raises: + PermissionError: If an API key is present but invalid/expired, + or if validation is not available in this FAB version. + """ + if not app.config.get("FAB_API_KEY_ENABLED", False) or not has_request_context(): + return None + + sm = app.appbuilder.sm + # _extract_api_key_from_request is FAB's internal method for reading + # the Bearer token from the Authorization header and matching prefixes. + # Not all FAB versions include this method, so guard with hasattr. + if not hasattr(sm, "_extract_api_key_from_request"): + logger.debug( + "FAB SecurityManager does not have _extract_api_key_from_request; " + "API key authentication is not available in this FAB version" + ) + return None + + api_key_string = sm._extract_api_key_from_request() + if api_key_string is None: + return None + + if not hasattr(sm, "validate_api_key"): + logger.warning( + "FAB SecurityManager does not have validate_api_key; " + "cannot validate API key" + ) + raise PermissionError( + "API key validation is not available in this FAB version." + ) + + user = sm.validate_api_key(api_key_string) + if not user: + raise PermissionError( + "Invalid or expired API key. " + "Create a new key at /api/v1/security/api_keys/." + ) + + # Reload user with all relationships eagerly loaded to avoid + # detached-instance errors during later permission checks. + user_with_rels = load_user_with_relationships(username=user.username) + if user_with_rels is None: + logger.warning( + "Failed to reload API key user %s with relationships; " + "using original user object which may have lazy-loaded " + "relationships", + user.username, + ) + return user + return user_with_rels + + +def get_user_from_request() -> User: + """ + Get the current user for the MCP tool request. + + Priority order: + 1. JWT auth context (per-request ContextVar from MCP SDK) — safest + 2. API key from Authorization header (via FAB SecurityManager) + 3. MCP_DEV_USERNAME from configuration (for development/testing) + 4. g.user fallback (for external middleware like Preset's + WorkspaceContextMiddleware that sets g.user fresh per request) + + This ordering prevents stale ``g.user`` from a previous tool call + from being used in open-source deployments where no middleware + refreshes ``g.user`` per request. + + Returns: + User object with roles and groups eagerly loaded + + Raises: + ValueError: If user cannot be authenticated or found + """ + from flask import current_app + + # Priority 1: JWT context (per-request safe via ContextVar) + if (jwt_user := _resolve_user_from_jwt_context(current_app)) is not None: + return jwt_user + + # Priority 2: API key authentication via FAB SecurityManager + if (api_key_user := _resolve_user_from_api_key(current_app)) is not None: + return api_key_user + + # Priority 3: Configured dev username for development/single-user deployments + if username := current_app.config.get("MCP_DEV_USERNAME"): + user = load_user_with_relationships(username) + if not user: + raise ValueError( + f"User '{username}' not found. " + f"Please create admin user with: superset fab create-admin" + ) + return user + + # Priority 4: g.user fallback (set by external middleware, e.g. Preset) + if hasattr(g, "user") and g.user: + return g.user + + # No auth source available — raise with diagnostic details + auth_enabled = current_app.config.get("MCP_AUTH_ENABLED", False) + jwt_configured = bool( + current_app.config.get("MCP_JWKS_URI") + or current_app.config.get("MCP_JWT_PUBLIC_KEY") + or current_app.config.get("MCP_JWT_SECRET") + ) + details = [ + f"No JWT access token in MCP request context " + f"(MCP_AUTH_ENABLED={auth_enabled}, " + f"JWT keys configured={jwt_configured})", + "No API key in Authorization header", + "MCP_DEV_USERNAME is not configured", + "g.user was not set by external middleware", + ] + configured_prefixes = current_app.config.get("FAB_API_KEY_PREFIXES", ["sst_"]) + prefix_example = configured_prefixes[0] if configured_prefixes else "sst_" + raise ValueError( + "No authenticated user found. Tried:\n" + + "\n".join(f" - {d}" for d in details) + + f"\n\nEither pass a valid API key (Bearer {prefix_example}...), " + "JWT token, or configure MCP_DEV_USERNAME for development." + ) + + def has_dataset_access(dataset: "SqlaTable") -> bool: """ Validate user has access to the dataset. @@ -355,6 +437,15 @@ def _setup_user_context() -> User | None: Returns: User object with roles and groups loaded, or None if no Flask context """ + # Clear stale g.user to prevent user impersonation across + # tool calls when no per-request middleware refreshes it. + # Only clear in app-context-only mode; preserve g.user when + # a request context is active (external middleware set it). + from flask import has_request_context + + if not has_request_context(): + g.pop("user", None) + try: user = get_user_from_request() except RuntimeError as e: diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index e1292037d3f6..b5e2d6b0a090 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -26,7 +26,9 @@ from dataclasses import dataclass from typing import Any, Dict +from superset.constants import NO_TIME_RANGE from superset.mcp_service.chart.schemas import ( + BigNumberChartConfig, ChartCapabilities, ChartSemantics, ColumnRef, @@ -40,6 +42,7 @@ ) from superset.mcp_service.utils.url_utils import get_superset_base_url from superset.utils import json +from superset.utils.core import FilterOperator logger = logging.getLogger(__name__) @@ -311,7 +314,8 @@ def map_config_to_form_data( | PieChartConfig | PivotTableChartConfig | MixedTimeseriesChartConfig - | HandlebarsChartConfig, + | HandlebarsChartConfig + | BigNumberChartConfig, dataset_id: int | str | None = None, ) -> Dict[str, Any]: """Map chart config to Superset form_data.""" @@ -327,6 +331,14 @@ def map_config_to_form_data( return map_mixed_timeseries_config(config, dataset_id=dataset_id) elif isinstance(config, HandlebarsChartConfig): return map_handlebars_config(config) + elif isinstance(config, BigNumberChartConfig): + if config.show_trendline and config.temporal_column: + if not is_column_truly_temporal(config.temporal_column, dataset_id): + raise ValueError( + f"Big Number trendline requires a temporal SQL column; " + f"'{config.temporal_column}' is not temporal." + ) + return map_big_number_config(config) else: raise ValueError(f"Unsupported config type: {type(config)}") @@ -381,8 +393,8 @@ def map_table_config(config: TableChartConfig) -> Dict[str, Any]: aggregated_metrics = [] for col in config.columns: - if col.aggregate: - # Column has aggregation - treat as metric + if col.is_metric: + # Saved metric or column with aggregation - treat as metric aggregated_metrics.append(create_metric_object(col)) else: # No aggregation - treat as raw column @@ -400,9 +412,12 @@ def map_table_config(config: TableChartConfig) -> Dict[str, Any]: # Handle raw columns (no aggregation) if raw_columns and not aggregated_metrics: # Pure raw columns - show individual rows + # Include both "all_columns" (Superset table viz) and "columns" + # (QueryContextFactory validation) to avoid "Empty query?" errors form_data.update( { "all_columns": raw_columns, + "columns": raw_columns, "query_mode": "raw", "include_time": False, "order_desc": True, @@ -441,8 +456,16 @@ def map_table_config(config: TableChartConfig) -> Dict[str, Any]: return form_data -def create_metric_object(col: ColumnRef) -> Dict[str, Any]: - """Create a metric object for a column with enhanced validation.""" +def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str: + """Create a metric object for a column with enhanced validation. + + For saved metrics, returns the metric name as a plain string which + Superset's query engine resolves via its metrics_by_name lookup. + For ad-hoc metrics, returns a SIMPLE expression dict. + """ + if col.saved_metric: + return col.name + # Ensure aggregate is valid - default to SUM if not specified or invalid valid_aggregates = { "SUM", @@ -527,6 +550,7 @@ def configure_temporal_handling( Stores any warnings in ``form_data["_mcp_warnings"]``. """ if x_is_temporal: + form_data["granularity_sqla"] = form_data.get("x_axis") if time_grain: form_data["time_grain_sqla"] = time_grain else: @@ -543,6 +567,33 @@ def configure_temporal_handling( ) +def _ensure_temporal_adhoc_filter(form_data: Dict[str, Any], column: str) -> None: + """Ensure a TEMPORAL_RANGE adhoc filter exists for the given column. + + Mirrors the Explore UI behavior: when a temporal column is set as + the x-axis, a TEMPORAL_RANGE filter must be present so dashboard + time-range filters can bind to it. Without this filter, Explore + shows a warning dialog asking the user to add it manually. + """ + existing = form_data.get("adhoc_filters", []) + if any( + f.get("operator") == FilterOperator.TEMPORAL_RANGE.value + and f.get("subject") == column + for f in existing + ): + return + existing.append( + { + "clause": "WHERE", + "expressionType": "SIMPLE", + "subject": column, + "operator": FilterOperator.TEMPORAL_RANGE.value, + "comparator": NO_TIME_RANGE, + } + ) + form_data["adhoc_filters"] = existing + + def map_xy_config( config: XYChartConfig, dataset_id: int | str | None = None ) -> Dict[str, Any]: @@ -600,6 +651,9 @@ def map_xy_config( _add_adhoc_filters(form_data, config.filters) + if x_is_temporal: + _ensure_temporal_adhoc_filter(form_data, config.x.name) + form_data["row_limit"] = config.row_limit # Add stacking configuration @@ -642,6 +696,46 @@ def map_pie_config(config: PieChartConfig) -> Dict[str, Any]: return form_data +def map_big_number_config(config: BigNumberChartConfig) -> Dict[str, Any]: + """Map big number chart config to Superset form_data.""" + # Determine viz_type: big_number (with trendline) or big_number_total + if config.show_trendline and config.temporal_column: + viz_type = "big_number" + else: + viz_type = "big_number_total" + + metric = create_metric_object(config.metric) + form_data: Dict[str, Any] = { + "viz_type": viz_type, + "metric": metric, + } + + if config.subheader: + form_data["subheader"] = config.subheader + + if config.y_axis_format: + form_data["y_axis_format"] = config.y_axis_format + + # Trendline-specific fields + if viz_type == "big_number": + # Big Number with trendline uses granularity_sqla for the temporal column + # (unlike XY charts which use x_axis). This is how Superset's + # big_number viz determines the time column for the trendline. + form_data["granularity_sqla"] = config.temporal_column + form_data["show_trend_line"] = True + form_data["start_y_axis_at_zero"] = config.start_y_axis_at_zero + + if config.time_grain: + form_data["time_grain_sqla"] = config.time_grain + + if config.compare_lag is not None: + form_data["compare_lag"] = config.compare_lag + + _add_adhoc_filters(form_data, config.filters) + + return form_data + + def map_handlebars_config(config: HandlebarsChartConfig) -> Dict[str, Any]: """Map handlebars chart config to Superset form_data.""" form_data: Dict[str, Any] = { @@ -840,6 +934,8 @@ def _humanize_column(col: ColumnRef) -> str: if col.label: return col.label name = col.name.replace("_", " ").title() + if col.saved_metric: + return name if col.aggregate: return f"{col.aggregate.capitalize()}({name})" return name @@ -874,9 +970,9 @@ def _truncate(name: str, max_length: int = 60) -> str: def _table_chart_what(config: TableChartConfig, dataset_name: str | None) -> str: """Build the descriptive fragment for a table chart.""" - has_agg = any(col.aggregate for col in config.columns) + has_agg = any(col.is_metric for col in config.columns) if has_agg: - metrics = [col for col in config.columns if col.aggregate] + metrics = [col for col in config.columns if col.is_metric] what = ", ".join(_humanize_column(m) for m in metrics[:2]) return f"{what} Summary" if dataset_name: @@ -965,13 +1061,31 @@ def _handlebars_chart_what(config: HandlebarsChartConfig) -> str: return "Handlebars Chart" +def _big_number_chart_what(config: BigNumberChartConfig) -> str: + """Build the 'what' portion for a big number chart name. + + Uses parentheses instead of en-dash to avoid collision with + ``generate_chart_name``'s ``\u2013`` context separator. + """ + if config.metric.label: + metric_label = config.metric.label + elif config.metric.aggregate: + metric_label = f"{config.metric.aggregate}({config.metric.name})" + else: + metric_label = config.metric.name + if config.show_trendline: + return f"Big Number ({metric_label}, trendline)" + return f"Big Number ({metric_label})" + + def generate_chart_name( config: TableChartConfig | XYChartConfig | PieChartConfig | PivotTableChartConfig | MixedTimeseriesChartConfig - | HandlebarsChartConfig, + | HandlebarsChartConfig + | BigNumberChartConfig, dataset_name: str | None = None, ) -> str: """Generate a descriptive chart name following a standard format. @@ -1005,6 +1119,9 @@ def generate_chart_name( elif isinstance(config, HandlebarsChartConfig): what = _handlebars_chart_what(config) context = _summarize_filters(getattr(config, "filters", None)) + elif isinstance(config, BigNumberChartConfig): + what = _big_number_chart_what(config) + context = _summarize_filters(getattr(config, "filters", None)) else: return "Chart" @@ -1036,6 +1153,12 @@ def _resolve_viz_type(config: Any) -> str: return "mixed_timeseries" elif chart_type == "handlebars": return "handlebars" + elif chart_type == "big_number": + show_trendline = getattr(config, "show_trendline", False) + temporal_column = getattr(config, "temporal_column", None) + return ( + "big_number" if show_trendline and temporal_column else "big_number_total" + ) return "unknown" @@ -1073,7 +1196,7 @@ def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilit # Classify data types data_types = [] if hasattr(config, "x") and config.x: - data_types.append("categorical" if not config.x.aggregate else "metric") + data_types.append("categorical" if not config.x.is_metric else "metric") if hasattr(config, "y") and config.y: data_types.extend(["metric"] * len(config.y)) if "time" in viz_type or "timeseries" in viz_type: @@ -1119,6 +1242,13 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics: "Renders data using a custom Handlebars HTML template for " "fully flexible layouts like KPI cards, leaderboards, and reports" ), + "big_number": ( + "Displays a key metric with a trendline showing " + "how the value changes over time" + ), + "big_number_total": ( + "Highlights a single key metric value as a prominent number" + ), } primary_insight = insights_map.get( diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py index dcc8642f0000..d585e3cda991 100644 --- a/superset/mcp_service/chart/preview_utils.py +++ b/superset/mcp_service/chart/preview_utils.py @@ -39,6 +39,12 @@ def _build_query_columns(form_data: Dict[str, Any]) -> list[str]: """Build query columns list from form_data, including both x_axis and groupby.""" + # Table charts in raw mode use all_columns or columns + all_columns = form_data.get("all_columns", []) + raw_columns_field = form_data.get("columns", []) + if form_data.get("query_mode") == "raw" and (all_columns or raw_columns_field): + return list(all_columns or raw_columns_field) + x_axis_config = form_data.get("x_axis") groupby_columns: list[str] = form_data.get("groupby") or [] raw_columns: list[str] = form_data.get("columns") or [] @@ -92,13 +98,23 @@ def generate_preview_from_form_data( query_filters = adhoc_filters_to_query_filters( form_data.get("adhoc_filters", []) ) + + # Big Number charts use singular "metric" instead of "metrics" + metrics = form_data.get("metrics", []) + if not metrics and form_data.get("metric"): + metrics = [form_data["metric"]] + + # Big Number with trendline uses granularity_sqla as the time column + if not columns and form_data.get("granularity_sqla"): + columns = [form_data["granularity_sqla"]] + factory = QueryContextFactory() query_context_obj = factory.create( datasource={"id": dataset_id, "type": "table"}, queries=[ { "columns": columns, - "metrics": form_data.get("metrics", []), + "metrics": metrics, "orderby": form_data.get("orderby", []), "row_limit": form_data.get("row_limit", 100), "filters": query_filters, diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index edf952dffb9f..143d60a422ff 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -25,6 +25,7 @@ from datetime import datetime, timezone from typing import Annotated, Any, Dict, List, Literal, Protocol +import humanize from pydantic import ( AliasChoices, AliasPath, @@ -36,6 +37,7 @@ model_validator, PositiveInt, ) +from typing_extensions import Self from superset.constants import TimeGrain from superset.daos.base import ColumnOperator, ColumnOperatorEnum @@ -46,6 +48,7 @@ QueryCacheControl, ) from superset.mcp_service.common.error_schemas import ChartGenerationError +from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE from superset.mcp_service.system.schemas import ( PaginationInfo, serialize_user_object, @@ -270,6 +273,13 @@ def validate_identifier_or_form_data_key(self) -> "GetChartInfoRequest": return self +def _humanize_timestamp(dt: datetime | None) -> str | None: + """Convert a datetime to a humanized string like '2 hours ago'.""" + if dt is None: + return None + return humanize.naturaltime(datetime.now() - dt) + + def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None: if not chart: return None @@ -295,11 +305,11 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None: or (str(chart.changed_by) if getattr(chart, "changed_by", None) else None), changed_by_name=getattr(chart, "changed_by_name", None), changed_on=getattr(chart, "changed_on", None), - changed_on_humanized=getattr(chart, "changed_on_humanized", None), + changed_on_humanized=_humanize_timestamp(getattr(chart, "changed_on", None)), created_by=getattr(chart, "created_by_name", None) or (str(chart.created_by) if getattr(chart, "created_by", None) else None), created_on=getattr(chart, "created_on", None), - created_on_humanized=getattr(chart, "created_on_humanized", None), + created_on_humanized=_humanize_timestamp(getattr(chart, "created_on", None)), uuid=str(getattr(chart, "uuid", "")) if getattr(chart, "uuid", None) else None, tags=[ TagInfo.model_validate(tag, from_attributes=True) @@ -485,6 +495,24 @@ class ColumnRef(BaseModel): ] | None ) = Field(None, description="SQL aggregate function") + saved_metric: bool = Field( + False, + description="If true, 'name' refers to a saved metric from the dataset " + "(use get_dataset_info to see available metrics). " + "When set, 'aggregate' is ignored.", + ) + + @property + def is_metric(self) -> bool: + """Whether this ref acts as a metric (has aggregate or is a saved metric).""" + return bool(self.aggregate) or self.saved_metric + + @model_validator(mode="after") + def clear_aggregate_for_saved_metric(self) -> "ColumnRef": + """Clear aggregate when saved_metric is True since it's ignored.""" + if self.saved_metric and self.aggregate is not None: + self.aggregate = None + return self @field_validator("name") @classmethod @@ -592,7 +620,9 @@ class PieChartConfig(UnknownFieldCheckMixin): validation_alias=AliasChoices("dimension", "groupby"), ) metric: ColumnRef = Field( - ..., description="Value metric (needs aggregate e.g. SUM, COUNT)" + ..., + description="Value metric (use aggregate e.g. SUM, COUNT for ad-hoc, " + "or set saved_metric=True for a saved dataset metric)", ) donut: bool = False show_labels: bool = True @@ -638,7 +668,8 @@ class PivotTableChartConfig(UnknownFieldCheckMixin): metrics: List[ColumnRef] = Field( ..., min_length=1, - description="Metrics (need aggregate e.g. SUM, COUNT, AVG)", + description="Metrics (use aggregate e.g. SUM, COUNT, AVG for ad-hoc, " + "or set saved_metric=True for saved dataset metrics)", ) aggregate_function: Literal[ "Sum", @@ -818,7 +849,7 @@ def validate_query_fields(self) -> "HandlebarsChartConfig": "Handlebars chart in 'aggregate' query mode requires 'metrics' " "field. Specify at least one metric with an aggregate function." ) - missing_agg = [m.name for m in self.metrics if not m.aggregate] + missing_agg = [m.name for m in self.metrics if not m.is_metric] if missing_agg: raise ValueError( f"Handlebars chart in 'aggregate' query mode requires an " @@ -829,6 +860,112 @@ def validate_query_fields(self) -> "HandlebarsChartConfig": return self +class BigNumberChartConfig(UnknownFieldCheckMixin): + model_config = ConfigDict(extra="ignore") + + chart_type: Literal["big_number"] = Field( + ..., + description=( + "Chart type discriminator - MUST be 'big_number'. " + "Creates Big Number charts that display a single prominent " + "metric value. Set show_trendline=True with a temporal_column " + "for a number with trendline, or leave show_trendline=False " + "for a standalone number." + ), + ) + metric: ColumnRef = Field( + ..., + description=( + "The metric to display as a big number. " + "Must include an aggregate function (e.g., SUM, COUNT)." + ), + ) + temporal_column: str | None = Field( + None, + description=( + "Temporal column for the trendline x-axis. " + "Required when show_trendline is True." + ), + min_length=1, + max_length=255, + pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$", + ) + time_grain: TimeGrain | None = Field( + None, + description=( + "Time granularity for trendline data. " + "Common values: PT1H (hour), P1D (day), P1W (week), " + "P1M (month), P1Y (year)." + ), + ) + show_trendline: bool = Field( + False, + description=( + "Show a trendline below the big number. " + "Requires 'temporal_column' to be set." + ), + ) + subheader: str | None = Field( + None, + description="Subtitle text displayed below the big number", + max_length=500, + ) + y_axis_format: str | None = Field( + None, + description=( + "Number format string for the metric value " + "(e.g., '$,.2f' for currency, ',.0f' for integers, " + "'.2%' for percentages)" + ), + max_length=50, + ) + start_y_axis_at_zero: bool = Field( + True, + description="Anchor trendline y-axis at zero", + ) + compare_lag: int | None = Field( + None, + description=( + "Number of time periods to compare against. " + "Displays a percentage change vs the prior period." + ), + ge=1, + ) + filters: list[FilterConfig] | None = Field( + None, + description="Filters to apply", + ) + + @model_validator(mode="after") + def validate_trendline_fields(self) -> Self: + """Validate trendline requires temporal column.""" + if self.show_trendline and not self.temporal_column: + raise ValueError( + "Big Number chart with show_trendline=True requires " + "'temporal_column'. Specify a date/time column for " + "the trendline x-axis." + ) + if self.compare_lag and not self.show_trendline: + raise ValueError( + "compare_lag requires show_trendline=True. " + "Period comparison is only available for " + "trendline charts." + ) + return self + + @model_validator(mode="after") + def validate_metric_aggregate(self) -> Self: + """Ensure metric is a valid metric reference (aggregate or saved).""" + if not self.metric.is_metric: + raise ValueError( + "Big Number metric must be either a saved dataset metric " + "or include an aggregate function (e.g., SUM, COUNT, AVG). " + "Set 'saved_metric': true to use a saved metric, or add " + "'aggregate' to the metric specification." + ) + return self + + class TableChartConfig(UnknownFieldCheckMixin): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -861,7 +998,9 @@ def validate_unique_column_labels(self) -> "TableChartConfig": for i, col in enumerate(self.columns): # Generate the label that will be used (same logic as create_metric_object) - if col.aggregate: + if col.saved_metric: + label = col.label or col.name + elif col.aggregate: label = col.label or f"{col.aggregate}({col.name})" else: label = col.label or col.name @@ -943,7 +1082,9 @@ def validate_unique_column_labels(self) -> "XYChartConfig": # Check Y-axis labels for i, col in enumerate(self.y): - if col.aggregate: + if col.saved_metric: + label = col.label or col.name + elif col.aggregate: label = col.label or f"{col.aggregate}({col.name})" else: label = col.label or col.name @@ -989,12 +1130,14 @@ def validate_unique_column_labels(self) -> "XYChartConfig": | PieChartConfig | PivotTableChartConfig | MixedTimeseriesChartConfig - | HandlebarsChartConfig, + | HandlebarsChartConfig + | BigNumberChartConfig, Field( discriminator="chart_type", description=( "Chart configuration - specify chart_type as 'xy', 'table', " - "'pie', 'pivot_table', 'mixed_timeseries', or 'handlebars'" + "'pie', 'pivot_table', 'mixed_timeseries', 'handlebars', " + "or 'big_number'" ), ), ] @@ -1069,7 +1212,13 @@ def parse_select_columns(cls, v: Any) -> List[str]: Field(default=1, description="Page number for pagination (1-based)"), ] page_size: Annotated[ - PositiveInt, Field(default=10, description="Number of items per page") + int, + Field( + default=DEFAULT_PAGE_SIZE, + gt=0, + le=MAX_PAGE_SIZE, + description=f"Number of items per page (max {MAX_PAGE_SIZE})", + ), ] @model_validator(mode="after") diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py index 2ba89cd71a84..095e74419b36 100644 --- a/superset/mcp_service/chart/tool/generate_chart.py +++ b/superset/mcp_service/chart/tool/generate_chart.py @@ -89,13 +89,23 @@ def _compile_chart( query_filters = adhoc_filters_to_query_filters( form_data.get("adhoc_filters", []) ) + + # Big Number charts use singular "metric" instead of "metrics" + metrics = form_data.get("metrics", []) + if not metrics and form_data.get("metric"): + metrics = [form_data["metric"]] + + # Big Number with trendline uses granularity_sqla as the time column + if not columns and form_data.get("granularity_sqla"): + columns = [form_data["granularity_sqla"]] + factory = QueryContextFactory() query_context = factory.create( datasource={"id": dataset_id, "type": "table"}, queries=[ { "columns": columns, - "metrics": form_data.get("metrics", []), + "metrics": metrics, "orderby": form_data.get("orderby", []), "row_limit": 2, "filters": query_filters, @@ -742,7 +752,13 @@ async def generate_chart( # noqa: C901 chart.id, exc_info=True, ) - db.session.rollback() + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during chart re-fetch error handling", + exc_info=True, + ) chart_data = { "id": chart.id, "slice_name": chart.slice_name, @@ -805,6 +821,14 @@ async def generate_chart( # noqa: C901 return GenerateChartResponse.model_validate(result) except (CommandException, SQLAlchemyError, KeyError, ValueError) as e: + from superset import db + + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during error handling", exc_info=True + ) await ctx.error( "Chart generation failed: error=%s, execution_time_ms=%s" % ( diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py index a958433b9f87..34f7f0fb3a76 100644 --- a/superset/mcp_service/chart/tool/get_chart_data.py +++ b/superset/mcp_service/chart/tool/get_chart_data.py @@ -369,6 +369,29 @@ async def get_chart_data( # noqa: C901 # Bubble charts use x/y/size as separate metric fields. viz_type = chart.viz_type or "" + # Deck.gl chart types store spatial data (lat/lon) + # rather than traditional metrics/groupby. They + # require a saved query_context to retrieve data. + # Match by prefix to cover all current and future + # deck.gl viz types (deck_arc, deck_scatter, etc.). + if viz_type.startswith("deck_"): + await ctx.warning( + "Chart %s is a deck.gl visualization (%s) with no " + "saved query_context. Data retrieval requires " + "re-saving the chart in Superset." % (chart.id, viz_type) + ) + return ChartError( + error=( + f"Chart {chart.id} is a deck.gl visualization " + f"(type: {viz_type}) with no saved query_context. " + f"Deck.gl charts use spatial data (lat/lon) that " + f"cannot be reconstructed from form_data alone. " + f"Please open this chart in Superset and re-save " + f"it to generate a query_context." + ), + error_type="MissingQueryContext", + ) + singular_metric_no_groupby = ( "big_number", "big_number_total", diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py index 548898e03b47..3f36d5378c20 100644 --- a/superset/mcp_service/chart/tool/get_chart_preview.py +++ b/superset/mcp_service/chart/tool/get_chart_preview.py @@ -133,12 +133,18 @@ def generate(self) -> ASCIIPreview | ChartError: groupby_columns = form_data.get("groupby", []) metrics = form_data.get("metrics", []) - columns = groupby_columns.copy() - if x_axis_config and isinstance(x_axis_config, str): - columns.append(x_axis_config) - elif x_axis_config and isinstance(x_axis_config, dict): - if "column_name" in x_axis_config: - columns.append(x_axis_config["column_name"]) + # Table charts in raw mode use all_columns or columns + all_columns = form_data.get("all_columns", []) + raw_columns = form_data.get("columns", []) + if form_data.get("query_mode") == "raw" and (all_columns or raw_columns): + columns = list(all_columns or raw_columns) + else: + columns = groupby_columns.copy() + if x_axis_config and isinstance(x_axis_config, str): + columns.append(x_axis_config) + elif x_axis_config and isinstance(x_axis_config, dict): + if "column_name" in x_axis_config: + columns.append(x_axis_config["column_name"]) factory = QueryContextFactory() query_context = factory.create( diff --git a/superset/mcp_service/chart/tool/list_charts.py b/superset/mcp_service/chart/tool/list_charts.py index 3f9bcfe88eb5..f952b43f1e17 100644 --- a/superset/mcp_service/chart/tool/list_charts.py +++ b/superset/mcp_service/chart/tool/list_charts.py @@ -47,6 +47,7 @@ "slice_name", "viz_type", "url", + "changed_on", "changed_on_humanized", ] diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py index a8f522c8758f..767ef615f8b2 100644 --- a/superset/mcp_service/chart/tool/update_chart.py +++ b/superset/mcp_service/chart/tool/update_chart.py @@ -23,6 +23,7 @@ import time from fastmcp import Context +from sqlalchemy.exc import SQLAlchemyError from superset_core.mcp.decorators import tool, ToolAnnotations from superset.commands.exceptions import CommandException @@ -268,7 +269,21 @@ async def update_chart( } return GenerateChartResponse.model_validate(result) - except (CommandException, ValueError, KeyError, AttributeError) as e: + except ( + CommandException, + SQLAlchemyError, + ValueError, + KeyError, + AttributeError, + ) as e: + from superset import db + + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during error handling", exc_info=True + ) execution_time = int((time.time() - start_time) * 1000) return GenerateChartResponse.model_validate( { diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py index 4adcdad93afe..e30d9690186c 100644 --- a/superset/mcp_service/chart/tool/update_chart_preview.py +++ b/superset/mcp_service/chart/tool/update_chart_preview.py @@ -39,10 +39,32 @@ PerformanceMetadata, UpdateChartPreviewRequest, ) +from superset.utils import json as utils_json logger = logging.getLogger(__name__) +def _get_old_adhoc_filters(form_data_key: str) -> list[Dict[str, Any]] | None: + """Retrieve adhoc_filters from the previously cached form_data.""" + from superset.commands.exceptions import CommandException + from superset.commands.explore.form_data.get import GetFormDataCommand + from superset.commands.explore.form_data.parameters import CommandParameters + + try: + cmd_params = CommandParameters(key=form_data_key) + cached_data = GetFormDataCommand(cmd_params).run() + if cached_data: + if isinstance(cached_data, str): + cached_data = utils_json.loads(cached_data) + if isinstance(cached_data, dict): + adhoc_filters = cached_data.get("adhoc_filters") + if adhoc_filters: + return adhoc_filters + except (KeyError, ValueError, TypeError, CommandException): + logger.debug("Could not retrieve old form_data for filter preservation") + return None + + @tool( tags=["mutate"], class_permission_name="Chart", @@ -81,6 +103,16 @@ def update_chart_preview( ) new_form_data.pop("_mcp_warnings", None) + # Preserve adhoc filters from the previous cached form_data + # when the new config doesn't explicitly specify filters + if ( + getattr(request.config, "filters", None) is None + and request.form_data_key + ): + old_adhoc_filters = _get_old_adhoc_filters(request.form_data_key) + if old_adhoc_filters: + new_form_data["adhoc_filters"] = old_adhoc_filters + # Generate new explore link with updated form_data explore_url = generate_explore_link(request.dataset_id, new_form_data) diff --git a/superset/mcp_service/chart/validation/dataset_validator.py b/superset/mcp_service/chart/validation/dataset_validator.py index 4f7e78cc4f0d..202d7cc5afc3 100644 --- a/superset/mcp_service/chart/validation/dataset_validator.py +++ b/superset/mcp_service/chart/validation/dataset_validator.py @@ -82,25 +82,19 @@ def validate_against_dataset( # Collect all column references column_refs = DatasetValidator._extract_column_references(config) - # Validate each column exists - invalid_columns = [] - for col_ref in column_refs: - if not DatasetValidator._column_exists(col_ref.name, dataset_context): - invalid_columns.append(col_ref) - - if invalid_columns: - # Generate suggestions for invalid columns - suggestions_map = {} - for col_ref in invalid_columns: - suggestions = DatasetValidator._get_column_suggestions( - col_ref.name, dataset_context - ) - suggestions_map[col_ref.name] = suggestions + # Validate saved metrics exist in dataset metrics specifically + invalid_saved = DatasetValidator._validate_saved_metrics( + column_refs, dataset_context + ) + if invalid_saved: + return False, invalid_saved - # Build error with suggestions - return False, DatasetValidator._build_column_error( - invalid_columns, suggestions_map, dataset_context - ) + # Validate columns exist (skip saved metrics — already validated above) + column_error = DatasetValidator._validate_columns_exist( + column_refs, dataset_context + ) + if column_error: + return False, column_error # Validate aggregation compatibility if isinstance(config, (TableChartConfig, XYChartConfig)): @@ -112,6 +106,32 @@ def validate_against_dataset( return True, None + @staticmethod + def _validate_columns_exist( + column_refs: List[ColumnRef], dataset_context: DatasetContext + ) -> ChartGenerationError | None: + """Validate that non-saved-metric column refs exist in the dataset.""" + invalid_columns = [] + for col_ref in column_refs: + if col_ref.saved_metric: + continue + if not DatasetValidator._column_exists(col_ref.name, dataset_context): + invalid_columns.append(col_ref) + + if not invalid_columns: + return None + + suggestions_map = {} + for col_ref in invalid_columns: + suggestions = DatasetValidator._get_column_suggestions( + col_ref.name, dataset_context + ) + suggestions_map[col_ref.name] = suggestions + + return DatasetValidator._build_column_error( + invalid_columns, suggestions_map, dataset_context + ) + @staticmethod def _get_dataset_context(dataset_id: int | str) -> DatasetContext | None: """Get dataset context with column information.""" @@ -418,6 +438,49 @@ def _build_column_error( error_code="MULTIPLE_INVALID_COLUMNS", ) + @staticmethod + def _validate_saved_metrics( + column_refs: List[ColumnRef], dataset_context: DatasetContext + ) -> ChartGenerationError | None: + """Validate that saved_metric refs exist in dataset metrics. + + A ColumnRef with saved_metric=True must match an entry in + available_metrics, not just available_columns. Without this check + a regular column name marked as saved_metric would pass + _column_exists (which checks both lists) but fail at query time. + """ + metric_names = {m["name"].lower() for m in dataset_context.available_metrics} + invalid = [ + col_ref.name + for col_ref in column_refs + if col_ref.saved_metric and col_ref.name.lower() not in metric_names + ] + if not invalid: + return None + + from superset.mcp_service.utils.error_builder import ChartErrorBuilder + + available = [m["name"] for m in dataset_context.available_metrics] + return ChartErrorBuilder.build_error( + error_type="invalid_saved_metric", + template_key="column_not_found", + template_vars={ + "column": ", ".join(invalid), + "suggestions": ( + f"Available saved metrics: {', '.join(available[:10])}" + if available + else "This dataset has no saved metrics" + ), + }, + custom_suggestions=[ + f"'{name}' is not a saved metric in this dataset. " + "Remove saved_metric=True to use it as a column with an aggregate, " + "or use get_dataset_info to see available saved metrics." + for name in invalid + ], + error_code="INVALID_SAVED_METRIC", + ) + @staticmethod def _validate_aggregations( column_refs: List[ColumnRef], dataset_context: DatasetContext @@ -426,6 +489,8 @@ def _validate_aggregations( errors = [] for col_ref in column_refs: + if col_ref.saved_metric: + continue # Saved metrics have built-in aggregation if not col_ref.aggregate: continue diff --git a/superset/mcp_service/chart/validation/schema_validator.py b/superset/mcp_service/chart/validation/schema_validator.py index 07ecb2b912ba..c82728b11bd5 100644 --- a/superset/mcp_service/chart/validation/schema_validator.py +++ b/superset/mcp_service/chart/validation/schema_validator.py @@ -134,6 +134,7 @@ def _pre_validate( "Add 'chart_type': 'pivot_table' for interactive pivot tables", "Add 'chart_type': 'mixed_timeseries' for dual-series time charts", "Add 'chart_type': 'handlebars' for custom HTML template charts", + "Add 'chart_type': 'big_number' for big number display", "Example: 'config': {'chart_type': 'xy', ...}", ], error_code="MISSING_CHART_TYPE", @@ -154,6 +155,7 @@ def _pre_validate_chart_type( "pivot_table": SchemaValidator._pre_validate_pivot_table_config, "mixed_timeseries": SchemaValidator._pre_validate_mixed_timeseries_config, "handlebars": SchemaValidator._pre_validate_handlebars_config, + "big_number": SchemaValidator._pre_validate_big_number_config, } if not isinstance(chart_type, str) or chart_type not in chart_type_validators: @@ -170,6 +172,7 @@ def _pre_validate_chart_type( "Use 'chart_type': 'pivot_table' for interactive pivot tables", "Use 'chart_type': 'mixed_timeseries' for dual-series time charts", "Use 'chart_type': 'handlebars' for custom HTML template charts", + "Use 'chart_type': 'big_number' for big number display", "Check spelling and ensure lowercase", ], error_code="INVALID_CHART_TYPE", @@ -362,6 +365,75 @@ def _pre_validate_handlebars_config( return True, None + @staticmethod + def _pre_validate_big_number_config( + config: Dict[str, Any], + ) -> Tuple[bool, ChartGenerationError | None]: + """Pre-validate big number chart configuration.""" + if "metric" not in config: + return False, ChartGenerationError( + error_type="missing_metric", + message="Big Number chart missing required field: metric", + details="Big Number charts require a 'metric' field " + "specifying the value to display", + suggestions=[ + "Add 'metric' with name and aggregate: " + "{'name': 'revenue', 'aggregate': 'SUM'}", + "The aggregate function is required (SUM, COUNT, AVG, MIN, MAX)", + "Example: {'chart_type': 'big_number', " + "'metric': {'name': 'sales', 'aggregate': 'SUM'}}", + ], + error_code="MISSING_BIG_NUMBER_METRIC", + ) + + metric = config.get("metric", {}) + if not isinstance(metric, dict): + return False, ChartGenerationError( + error_type="invalid_metric_type", + message="Big Number metric must be a dict with 'name' and 'aggregate'", + details="The 'metric' field must be an object, " + f"got {type(metric).__name__}", + suggestions=[ + "Use a dict: {'name': 'col', 'aggregate': 'SUM'}", + "Valid aggregates: SUM, COUNT, AVG, MIN, MAX", + ], + error_code="INVALID_BIG_NUMBER_METRIC_TYPE", + ) + if not metric.get("aggregate") and not metric.get("saved_metric"): + return False, ChartGenerationError( + error_type="missing_metric_aggregate", + message="Big Number metric must include an aggregate function " + "or reference a saved metric", + details="The metric must have an 'aggregate' field " + "or 'saved_metric': true", + suggestions=[ + "Add 'aggregate' to your metric: " + "{'name': 'col', 'aggregate': 'SUM'}", + "Or use a saved metric: " + "{'name': 'total_sales', 'saved_metric': true}", + "Valid aggregates: SUM, COUNT, AVG, MIN, MAX", + ], + error_code="MISSING_BIG_NUMBER_AGGREGATE", + ) + + show_trendline = config.get("show_trendline", False) + temporal_column = config.get("temporal_column") + if show_trendline and not temporal_column: + return False, ChartGenerationError( + error_type="missing_temporal_column", + message="Trendline requires a temporal column", + details="When 'show_trendline' is True, a " + "'temporal_column' must be specified", + suggestions=[ + "Add 'temporal_column': 'date_column_name'", + "Or set 'show_trendline': false for number only", + "Use get_dataset_info to find temporal columns", + ], + error_code="MISSING_TEMPORAL_COLUMN", + ) + + return True, None + @staticmethod def _pre_validate_pivot_table_config( config: Dict[str, Any], @@ -526,6 +598,23 @@ def _enhance_validation_error( ], error_code="HANDLEBARS_VALIDATION_ERROR", ) + elif chart_type == "big_number": + return ChartGenerationError( + error_type="big_number_validation_error", + message="Big Number chart configuration validation failed", + details="The Big Number chart configuration is " + "missing required fields or has invalid " + "structure", + suggestions=[ + "Ensure 'metric' field has 'name' and 'aggregate'", + "Example: 'metric': {'name': 'revenue', " + "'aggregate': 'SUM'}", + "For trendline: add 'show_trendline': true " + "and 'temporal_column': 'date_col'", + "Without trendline: just provide the metric", + ], + error_code="BIG_NUMBER_VALIDATION_ERROR", + ) # Default enhanced error error_details = [] diff --git a/superset/mcp_service/constants.py b/superset/mcp_service/constants.py index 7abf91147a8e..a23a7949e948 100644 --- a/superset/mcp_service/constants.py +++ b/superset/mcp_service/constants.py @@ -16,6 +16,10 @@ # under the License. """Constants for the MCP service.""" +# Pagination defaults +DEFAULT_PAGE_SIZE = 10 # Default number of items per page +MAX_PAGE_SIZE = 100 # Maximum allowed page_size to prevent oversized responses + # Response size guard defaults DEFAULT_TOKEN_LIMIT = 25_000 # ~25k tokens prevents overwhelming LLM context windows DEFAULT_WARN_THRESHOLD_PCT = 80 # Log warnings above 80% of limit diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 97661d1c7949..552a30071e30 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -68,6 +68,7 @@ from datetime import datetime from typing import Annotated, Any, Dict, List, Literal, TYPE_CHECKING +import humanize from pydantic import ( BaseModel, ConfigDict, @@ -84,6 +85,7 @@ from superset.daos.base import ColumnOperator, ColumnOperatorEnum from superset.mcp_service.chart.schemas import ChartInfo, serialize_chart_object from superset.mcp_service.common.cache_schemas import MetadataCacheControl +from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE from superset.mcp_service.system.schemas import ( PaginationInfo, RoleInfo, @@ -91,6 +93,10 @@ TagInfo, UserInfo, ) +from superset.mcp_service.utils.sanitization import ( + _remove_dangerous_unicode, + _strip_html_tags, +) class DashboardError(BaseModel): @@ -244,7 +250,13 @@ def parse_select_columns(cls, v: Any) -> List[str]: Field(default=1, description="Page number for pagination (1-based)"), ] page_size: Annotated[ - PositiveInt, Field(default=10, description="Number of items per page") + int, + Field( + default=DEFAULT_PAGE_SIZE, + gt=0, + le=MAX_PAGE_SIZE, + description=f"Number of items per page (max {MAX_PAGE_SIZE})", + ), ] @model_validator(mode="after") @@ -440,6 +452,16 @@ class GenerateDashboardRequest(BaseModel): default=True, description="Whether to publish the dashboard" ) + @field_validator("dashboard_title") + @classmethod + def sanitize_dashboard_title(cls, v: str | None) -> str | None: + """Strip HTML tags from dashboard title to prevent XSS.""" + if v is None: + return None + v = _strip_html_tags(v.strip()) + v = _remove_dangerous_unicode(v) + return v + class GenerateDashboardResponse(BaseModel): """Response schema for dashboard generation.""" @@ -508,6 +530,13 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo: ) +def _humanize_timestamp(dt: datetime | None) -> str | None: + """Convert a datetime to a humanized string like '2 hours ago'.""" + if dt is None: + return None + return humanize.naturaltime(datetime.now() - dt) + + def serialize_dashboard_object(dashboard: Any) -> DashboardInfo: """Simple dashboard serializer that safely handles object attributes.""" from superset.mcp_service.utils.url_utils import get_superset_base_url @@ -530,10 +559,14 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo: published=getattr(dashboard, "published", None), changed_by=getattr(dashboard, "changed_by_name", None), changed_on=getattr(dashboard, "changed_on", None), - changed_on_humanized=getattr(dashboard, "changed_on_humanized", None), + changed_on_humanized=_humanize_timestamp( + getattr(dashboard, "changed_on", None) + ), created_by=getattr(dashboard, "created_by_name", None), created_on=getattr(dashboard, "created_on", None), - created_on_humanized=getattr(dashboard, "created_on_humanized", None), + created_on_humanized=_humanize_timestamp( + getattr(dashboard, "created_on", None) + ), description=getattr(dashboard, "description", None), css=getattr(dashboard, "css", None), certified_by=getattr(dashboard, "certified_by", None), @@ -547,8 +580,9 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo: else None, chart_count=len(getattr(dashboard, "slices", [])), owners=[ - UserInfo.model_validate(owner, from_attributes=True) + info for owner in getattr(dashboard, "owners", []) + if (info := serialize_user_object(owner)) is not None ] if getattr(dashboard, "owners", None) else [], diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py index ede847b8580e..888f2423fcc3 100644 --- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py +++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py @@ -26,8 +26,10 @@ from typing import Any, Dict from fastmcp import Context +from sqlalchemy.exc import SQLAlchemyError from superset_core.mcp.decorators import tool, ToolAnnotations +from superset.commands.exceptions import CommandException from superset.extensions import event_logger from superset.mcp_service.chart.schemas import serialize_chart_object from superset.mcp_service.dashboard.constants import ( @@ -289,8 +291,17 @@ def _ensure_layout_structure( if "ROOT_ID" in layout: if "children" not in layout["ROOT_ID"]: layout["ROOT_ID"]["children"] = [] - if "GRID_ID" not in layout["ROOT_ID"]["children"]: - layout["ROOT_ID"]["children"].append("GRID_ID") + # Only add GRID_ID to ROOT_ID when TABS are not already a direct + # child of ROOT_ID. Real Superset dashboards with tabs place a + # TABS container directly under ROOT_ID (ROOT_ID → TABS → TABs). + # Adding GRID_ID as a sibling of TABS confuses the frontend layout + # engine and makes charts invisible. + root_children = layout["ROOT_ID"]["children"] + has_tabs_under_root = any( + layout.get(c, {}).get("type") == "TABS" for c in root_children + ) + if not has_tabs_under_root and "GRID_ID" not in root_children: + root_children.append("GRID_ID") else: # Create ROOT_ID if it doesn't exist layout["ROOT_ID"] = { @@ -320,10 +331,6 @@ def add_chart_to_existing_dashboard( Add chart to existing dashboard. Auto-positions in 2-column grid. Returns updated dashboard info. """ - from sqlalchemy.exc import SQLAlchemyError - - from superset.commands.exceptions import CommandException - try: from superset.commands.dashboard.update import UpdateDashboardCommand from superset.daos.dashboard import DashboardDAO @@ -457,7 +464,13 @@ def add_chart_to_existing_dashboard( updated_dashboard.id, exc_info=True, ) - db.session.rollback() + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during dashboard re-fetch error handling", + exc_info=True, + ) dashboard_url = ( f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/" ) @@ -470,6 +483,8 @@ def add_chart_to_existing_dashboard( dashboard=DashboardInfo( id=updated_dashboard.id, dashboard_title=updated_dashboard.dashboard_title, + published=updated_dashboard.published, + chart_count=len(all_chart_objects), url=dashboard_url, ), dashboard_url=dashboard_url, @@ -533,6 +548,14 @@ def add_chart_to_existing_dashboard( ) except (CommandException, SQLAlchemyError, KeyError, ValueError) as e: + from superset import db + + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during error handling", exc_info=True + ) logger.error("Error adding chart to dashboard: %s", e) return AddChartToDashboardResponse( dashboard=None, diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py index e4559d9f0f81..1b0f457771f8 100644 --- a/superset/mcp_service/dashboard/tool/generate_dashboard.py +++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py @@ -187,7 +187,7 @@ def _generate_title_from_charts(chart_objects: List[Any]) -> str: destructiveHint=False, ), ) -def generate_dashboard( +def generate_dashboard( # noqa: C901 request: GenerateDashboardRequest, ctx: Context ) -> GenerateDashboardResponse: """Create dashboard from chart IDs. @@ -323,9 +323,15 @@ def generate_dashboard( dashboard.slices = fresh_charts db.session.add(dashboard) - db.session.commit() + db.session.commit() # pylint: disable=consider-using-transaction except SQLAlchemyError as db_err: - db.session.rollback() + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during error handling", + exc_info=True, + ) logger.error( "Dashboard creation failed: %s", db_err, @@ -365,7 +371,13 @@ def generate_dashboard( dashboard.id, exc_info=True, ) - db.session.rollback() + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during dashboard re-fetch error handling", + exc_info=True, + ) dashboard_url = ( f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/" ) @@ -429,6 +441,14 @@ def generate_dashboard( ) except (SQLAlchemyError, ValueError, AttributeError, ValidationError) as e: + from superset import db + + try: + db.session.rollback() # pylint: disable=consider-using-transaction + except SQLAlchemyError: + logger.warning( + "Database rollback failed during error handling", exc_info=True + ) logger.error("Error creating dashboard: %s", e, exc_info=True) return GenerateDashboardResponse( dashboard=None, diff --git a/superset/mcp_service/dashboard/tool/list_dashboards.py b/superset/mcp_service/dashboard/tool/list_dashboards.py index ff277406140a..380291ce20ab 100644 --- a/superset/mcp_service/dashboard/tool/list_dashboards.py +++ b/superset/mcp_service/dashboard/tool/list_dashboards.py @@ -49,6 +49,7 @@ "dashboard_title", "slug", "url", + "changed_on", "changed_on_humanized", ] diff --git a/superset/mcp_service/dataset/schemas.py b/superset/mcp_service/dataset/schemas.py index 1fc5d67e1120..5ae92cdd6fb5 100644 --- a/superset/mcp_service/dataset/schemas.py +++ b/superset/mcp_service/dataset/schemas.py @@ -24,6 +24,7 @@ from datetime import datetime from typing import Annotated, Any, Dict, List, Literal +import humanize from pydantic import ( BaseModel, ConfigDict, @@ -35,6 +36,7 @@ from superset.daos.base import ColumnOperator, ColumnOperatorEnum from superset.mcp_service.common.cache_schemas import MetadataCacheControl +from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE from superset.mcp_service.system.schemas import ( PaginationInfo, serialize_user_object, @@ -83,7 +85,11 @@ class TableColumnInfo(BaseModel): class SqlMetricInfo(BaseModel): - metric_name: str = Field(..., description="Metric name") + metric_name: str = Field( + ..., + description="Saved metric name. In chart configs, reference as " + '{"name": "", "saved_metric": true}.', + ) verbose_name: str | None = Field(None, description="Verbose name") expression: str | None = Field(None, description="SQL expression") description: str | None = Field(None, description="Metric description") @@ -132,7 +138,9 @@ class DatasetInfo(BaseModel): default_factory=list, description="Columns in the dataset" ) metrics: List[SqlMetricInfo] = Field( - default_factory=list, description="Metrics in the dataset" + default_factory=list, + description="Saved metrics (pre-defined aggregations). " + "NOT columns — use saved_metric=true in chart configs.", ) is_favorite: bool | None = Field( None, description="Whether this dataset is favorited by the current user" @@ -247,7 +255,13 @@ class ListDatasetsRequest(MetadataCacheControl): Field(default=1, description="Page number for pagination (1-based)"), ] page_size: Annotated[ - PositiveInt, Field(default=10, description="Number of items per page") + int, + Field( + default=DEFAULT_PAGE_SIZE, + gt=0, + le=MAX_PAGE_SIZE, + description=f"Number of items per page (max {MAX_PAGE_SIZE})", + ), ] @model_validator(mode="after") @@ -300,6 +314,13 @@ def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None: return value +def _humanize_timestamp(dt: datetime | None) -> str | None: + """Convert a datetime to a humanized string like '2 hours ago'.""" + if dt is None: + return None + return humanize.naturaltime(datetime.now() - dt) + + def serialize_dataset_object(dataset: Any) -> DatasetInfo | None: if not dataset: return None @@ -342,11 +363,11 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None: changed_by=getattr(dataset, "changed_by_name", None) or (str(dataset.changed_by) if getattr(dataset, "changed_by", None) else None), changed_on=getattr(dataset, "changed_on", None), - changed_on_humanized=getattr(dataset, "changed_on_humanized", None), + changed_on_humanized=_humanize_timestamp(getattr(dataset, "changed_on", None)), created_by=getattr(dataset, "created_by_name", None) or (str(dataset.created_by) if getattr(dataset, "created_by", None) else None), created_on=getattr(dataset, "created_on", None), - created_on_humanized=getattr(dataset, "created_on_humanized", None), + created_on_humanized=_humanize_timestamp(getattr(dataset, "created_on", None)), tags=[ TagInfo.model_validate(tag, from_attributes=True) for tag in getattr(dataset, "tags", []) diff --git a/superset/mcp_service/dataset/tool/get_dataset_info.py b/superset/mcp_service/dataset/tool/get_dataset_info.py index ee74db8c1a09..c211c618d637 100644 --- a/superset/mcp_service/dataset/tool/get_dataset_info.py +++ b/superset/mcp_service/dataset/tool/get_dataset_info.py @@ -62,6 +62,11 @@ async def get_dataset_info( - DO NOT use schema.table_name format (e.g., "public.customers") - To find a dataset ID, use the list_datasets tool first + IMPORTANT - Saved Metrics vs Columns: + The response includes both 'columns' (raw database columns) and 'metrics' + (pre-defined saved metrics). When building chart configs, use saved_metric=true + for metrics — do not treat them as columns. See instructions for details. + Example usage: ```json { diff --git a/superset/mcp_service/dataset/tool/list_datasets.py b/superset/mcp_service/dataset/tool/list_datasets.py index 95e37fcaece7..96d493e5b983 100644 --- a/superset/mcp_service/dataset/tool/list_datasets.py +++ b/superset/mcp_service/dataset/tool/list_datasets.py @@ -48,6 +48,7 @@ "id", "table_name", "schema", + "changed_on", "changed_on_humanized", ] diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py b/superset/mcp_service/explore/tool/generate_explore_link.py index 988ca58541e8..bbf1b018c449 100644 --- a/superset/mcp_service/explore/tool/generate_explore_link.py +++ b/superset/mcp_service/explore/tool/generate_explore_link.py @@ -97,7 +97,38 @@ async def generate_explore_link( ) try: - await ctx.report_progress(1, 3, "Converting configuration to form data") + await ctx.report_progress(1, 4, "Validating dataset exists") + with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"): + from superset.daos.dataset import DatasetDAO + + dataset = None + if isinstance(request.dataset_id, int) or ( + isinstance(request.dataset_id, str) and request.dataset_id.isdigit() + ): + dataset_id_int = ( + int(request.dataset_id) + if isinstance(request.dataset_id, str) + else request.dataset_id + ) + dataset = DatasetDAO.find_by_id(dataset_id_int) + else: + dataset = DatasetDAO.find_by_id(request.dataset_id, id_column="uuid") + + if not dataset: + await ctx.error( + "Dataset not found: dataset_id=%s" % (request.dataset_id,) + ) + return { + "url": "", + "form_data": {}, + "form_data_key": None, + "error": ( + f"Dataset not found: {request.dataset_id}. " + "Use list_datasets to find valid dataset IDs." + ), + } + + await ctx.report_progress(2, 4, "Converting configuration to form data") with event_logger.log_context(action="mcp.generate_explore_link.form_data"): # Normalize column names to match canonical dataset column names # This fixes case sensitivity issues (e.g., 'order_date' vs 'OrderDate') @@ -131,7 +162,7 @@ async def generate_explore_link( ) ) - await ctx.report_progress(2, 3, "Generating explore URL") + await ctx.report_progress(3, 4, "Generating explore URL") with event_logger.log_context( action="mcp.generate_explore_link.url_generation" ): @@ -149,7 +180,7 @@ async def generate_explore_link( if form_data_key_list: form_data_key = form_data_key_list[0] - await ctx.report_progress(3, 3, "URL generation complete") + await ctx.report_progress(4, 4, "URL generation complete") await ctx.info( "Explore link generated successfully: url_length=%s, dataset_id=%s, " "form_data_key=%s" diff --git a/superset/mcp_service/mcp_config.py b/superset/mcp_service/mcp_config.py index e1e65c11f0ba..7142e69bc48d 100644 --- a/superset/mcp_service/mcp_config.py +++ b/superset/mcp_service/mcp_config.py @@ -318,11 +318,30 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]: return None -def default_user_resolver(app: Any, access_token: Any) -> Optional[str]: - """Extract username from JWT token claims.""" - if hasattr(access_token, "subject"): +def default_user_resolver(app: Any, access_token: Any) -> str | None: + """Extract username from JWT token claims. + + Checks the ``claims`` dict first (FastMCP's AccessToken format), + then falls back to legacy attribute access for backward compatibility. + """ + # FastMCP AccessToken stores JWT claims in a dict + claims = getattr(access_token, "claims", None) + if isinstance(claims, dict) and claims: + # Prefer human-readable username claims over opaque `sub` + # (OIDC `sub` is often a stable opaque ID, not a Superset username) + username = ( + claims.get("preferred_username") + or claims.get("username") + or claims.get("email") + or claims.get("sub") + ) + if username: + return username + + # Legacy attribute access for backward compatibility + if hasattr(access_token, "subject") and access_token.subject: return access_token.subject - if hasattr(access_token, "client_id"): + if hasattr(access_token, "client_id") and access_token.client_id: return access_token.client_id if hasattr(access_token, "payload") and isinstance(access_token.payload, dict): return ( diff --git a/superset/mcp_service/mcp_core.py b/superset/mcp_service/mcp_core.py index 0b1a817d26d1..051aa20809a7 100644 --- a/superset/mcp_service/mcp_core.py +++ b/superset/mcp_service/mcp_core.py @@ -142,6 +142,11 @@ def run_tool( page: int = 0, page_size: int = 10, ) -> L: + from superset.mcp_service.constants import MAX_PAGE_SIZE + + # Clamp page_size to MAX_PAGE_SIZE as defense-in-depth + page_size = min(page_size, MAX_PAGE_SIZE) + # Parse filters using generic utility (accepts JSON string or object) from superset.mcp_service.utils.schema_utils import ( parse_json_or_list, diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py b/superset/mcp_service/sql_lab/tool/execute_sql.py index 18d89b3b2712..edfbc9c8a509 100644 --- a/superset/mcp_service/sql_lab/tool/execute_sql.py +++ b/superset/mcp_service/sql_lab/tool/execute_sql.py @@ -36,8 +36,7 @@ QueryStatus, ) -from superset.errors import ErrorLevel, SupersetError, SupersetErrorType -from superset.exceptions import SupersetErrorException, SupersetSecurityException +from superset.errors import SupersetErrorType from superset.extensions import event_logger from superset.mcp_service.sql_lab.schemas import ( ColumnInfo, @@ -91,21 +90,23 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes db.session.query(Database).filter_by(id=request.database_id).first() ) if not database: - raise SupersetErrorException( - SupersetError( - message=f"Database with ID {request.database_id} not found", - error_type=SupersetErrorType.DATABASE_NOT_FOUND_ERROR, - level=ErrorLevel.ERROR, - ) + await ctx.error( + "Database not found: database_id=%s" % request.database_id + ) + return ExecuteSqlResponse( + success=False, + error=f"Database with ID {request.database_id} not found", + error_type=SupersetErrorType.DATABASE_NOT_FOUND_ERROR.value, ) if not security_manager.can_access_database(database): - raise SupersetSecurityException( - SupersetError( - message=(f"Access denied to database {database.database_name}"), - error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR, - level=ErrorLevel.ERROR, - ) + await ctx.error( + "Access denied to database: %s" % database.database_name + ) + return ExecuteSqlResponse( + success=False, + error=f"Access denied to database {database.database_name}", + error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR.value, ) # 2. Build QueryOptions and execute query diff --git a/superset/views/sql_lab/views.py b/superset/views/sql_lab/views.py index 4ac9b51fcc46..507252fe6c73 100644 --- a/superset/views/sql_lab/views.py +++ b/superset/views/sql_lab/views.py @@ -52,6 +52,23 @@ def _get_owner_id(tab_state_id: int) -> int: class TabStateView(BaseSupersetView): + # These are the fields that are allowed to be updated via the PUT endpoint + # to prevent mass assignment vulnerabilities. + ALLOWED_UPDATE_FIELDS = { + "label", + "active", + "database_id", + "catalog", + "schema", + "sql", + "query_limit", + "latest_query_id", + "autorun", + "template_params", + "hide_left_bar", + "saved_query_id", + } + @has_access_api @expose("/", methods=("POST",)) def post(self) -> FlaskResponse: @@ -153,7 +170,11 @@ def put(self, tab_state_id: int) -> FlaskResponse: return Response(status=403) try: - fields = {k: json.loads(v) for k, v in request.form.to_dict().items()} + fields = { + k: json.loads(v) + for k, v in request.form.to_dict().items() + if k in self.ALLOWED_UPDATE_FIELDS + } db.session.query(TabState).filter_by(id=tab_state_id).update(fields) db.session.commit() return json_success(json.dumps(tab_state_id)) diff --git a/tests/integration_tests/sql_lab/api_tests.py b/tests/integration_tests/sql_lab/api_tests.py index 9454e7c9b104..30cb7161e43d 100644 --- a/tests/integration_tests/sql_lab/api_tests.py +++ b/tests/integration_tests/sql_lab/api_tests.py @@ -37,7 +37,7 @@ get_example_database, ) # noqa: F401 from superset.utils import core as utils, json -from superset.models.sql_lab import Query +from superset.models.sql_lab import Query, TabState from tests.integration_tests.base_tests import SupersetTestCase from tests.integration_tests.constants import ( @@ -189,6 +189,43 @@ def test_delete_tab_already_removed(self): resp = self.client.delete("/tabstateview/" + str(tab_state_id)) assert resp.status_code == 404 + @pytest.mark.usefixtures("create_gamma_sqllab_no_data") + @mock.patch.dict( + "superset.extensions.feature_flag_manager._feature_flags", + {"SQLLAB_BACKEND_PERSISTENCE": True}, + clear=True, + ) + def test_put_tab_state_mass_assignment(self): + self.login(GAMMA_SQLLAB_NO_DATA_USERNAME) + + # create a tab + data = { + "queryEditor": json.dumps( + { + "title": "Untitled Query 1", + "dbId": 1, + "schema": None, + "autorun": False, + "sql": "SELECT ...", + "queryLimit": 1000, + } + ) + } + resp = self.get_json_resp("/tabstateview/", data=data) + tab_state_id = resp["id"] + + tab_state = db.session.query(TabState).filter_by(id=tab_state_id).one() + initial_user_id = tab_state.user_id + + # Attempt to update user_id via PUT (mass assignment) + # We'll try to change it to another user id. + attacker_data = {"user_id": json.dumps(initial_user_id + 1)} + self.client.put(f"/tabstateview/{tab_state_id}", data=attacker_data) + + db.session.expire_all() + tab_state = db.session.query(TabState).filter_by(id=tab_state_id).one() + assert tab_state.user_id == initial_user_id + def test_get_access_denied(self): new_role = Role(name="Dummy Role", permissions=[]) db.session.add(new_role) diff --git a/tests/unit_tests/datasets/api_tests.py b/tests/unit_tests/datasets/api_tests.py index 5296c1bba2a2..82e8453c8784 100644 --- a/tests/unit_tests/datasets/api_tests.py +++ b/tests/unit_tests/datasets/api_tests.py @@ -16,6 +16,7 @@ # under the License. from typing import Any +from unittest.mock import MagicMock, patch from sqlalchemy.orm.session import Session @@ -72,3 +73,50 @@ def test_put_invalid_dataset( } ] } + + +def test_get_dataset_include_rendered_sql_passes_table_to_template_processor( + session: Session, + client: Any, + full_api_access: None, +) -> None: + """ + Dataset API: Test that include_rendered_sql passes the table + to get_template_processor. + + Regression test for the bug where get_template_processor was called without + the `table` argument, leaving self._schema as None in processors like + PrestoTemplateProcessor and causing NPEs when templates reference partition + functions without an explicit schema. + """ + from superset.connectors.sqla.models import SqlaTable + from superset.models.core import Database + + SqlaTable.metadata.create_all(db.session.get_bind()) + + database = Database( + database_name="my_db", + sqlalchemy_uri="sqlite://", + ) + dataset = SqlaTable( + table_name="test_render_sql_table", + schema="my_schema", + database=database, + sql="SELECT 1", + ) + db.session.add(dataset) + db.session.flush() + + mock_processor = MagicMock() + mock_processor.process_template.return_value = "SELECT 1" + + with patch( + "superset.datasets.api.get_template_processor", + return_value=mock_processor, + ) as mock_get_processor: + response = client.get( + f"/api/v1/dataset/{dataset.id}?include_rendered_sql=true", + ) + + assert response.status_code == 200 + mock_get_processor.assert_called_once_with(database=database, table=dataset) diff --git a/tests/unit_tests/db_engine_specs/test_presto.py b/tests/unit_tests/db_engine_specs/test_presto.py index d73b46f861dd..8dd31a4f9c99 100644 --- a/tests/unit_tests/db_engine_specs/test_presto.py +++ b/tests/unit_tests/db_engine_specs/test_presto.py @@ -42,17 +42,17 @@ ( "TIMESTAMP", datetime(2022, 1, 1, 1, 23, 45, 600000), - "TIMESTAMP '2022-01-01 01:23:45.600000'", + "TIMESTAMP '2022-01-01 01:23:45.600'", ), ( "TIMESTAMP WITH TIME ZONE", datetime(2022, 1, 1, 1, 23, 45, 600000), - "TIMESTAMP '2022-01-01 01:23:45.600000'", + "TIMESTAMP '2022-01-01 01:23:45.600'", ), ( "TIMESTAMP WITH TIME ZONE", datetime(2022, 1, 1, 1, 23, 45, 600000, tzinfo=pytz.UTC), - "TIMESTAMP '2022-01-01 01:23:45.600000+00:00'", + "TIMESTAMP '2022-01-01 01:23:45.600+00:00'", ), ], ) diff --git a/tests/unit_tests/mcp_service/chart/test_big_number_chart.py b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py new file mode 100644 index 000000000000..59e142333bdb --- /dev/null +++ b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py @@ -0,0 +1,524 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for Big Number chart type support in MCP service.""" + +import pytest +from pydantic import ValidationError + +from superset.mcp_service.chart.chart_utils import ( + _resolve_viz_type, + analyze_chart_capabilities, + analyze_chart_semantics, + generate_chart_name, + map_big_number_config, + map_config_to_form_data, +) +from superset.mcp_service.chart.schemas import ( + BigNumberChartConfig, + ColumnRef, + FilterConfig, +) +from superset.mcp_service.chart.validation.schema_validator import ( + SchemaValidator, +) + + +class TestBigNumberChartConfig: + """Test BigNumberChartConfig Pydantic schema.""" + + def test_minimal_config(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + assert config.chart_type == "big_number" + assert config.metric.name == "revenue" + assert config.metric.aggregate == "SUM" + assert config.show_trendline is False + + def test_with_trendline(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + ) + assert config.show_trendline is True + assert config.temporal_column == "ds" + + def test_trendline_without_temporal_column_fails(self) -> None: + with pytest.raises(ValidationError, match="requires 'temporal_column'"): + BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + show_trendline=True, + ) + + def test_metric_without_aggregate_fails(self) -> None: + with pytest.raises(ValidationError, match="saved dataset metric"): + BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue"), + ) + + def test_saved_metric_accepted(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="total_sales", saved_metric=True), + ) + assert config.metric.saved_metric is True + assert config.metric.is_metric is True + + def test_saved_metric_passes_pre_validation(self) -> None: + """Verify saved metrics pass through SchemaValidator pre-validation.""" + data = { + "chart_type": "big_number", + "metric": {"name": "total_sales", "saved_metric": True}, + } + is_valid, error = SchemaValidator._pre_validate_big_number_config(data) + assert is_valid is True + assert error is None + + def test_with_subheader(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + subheader="Total revenue this quarter", + ) + assert config.subheader == "Total revenue this quarter" + + def test_with_y_axis_format(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + y_axis_format="$,.2f", + ) + assert config.y_axis_format == "$,.2f" + + def test_with_compare_lag(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + compare_lag=1, + ) + assert config.compare_lag == 1 + + def test_compare_lag_zero_fails(self) -> None: + with pytest.raises(ValidationError, match="greater than or equal"): + BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + compare_lag=0, + ) + + def test_compare_lag_requires_trendline(self) -> None: + with pytest.raises( + ValidationError, match="compare_lag requires show_trendline" + ): + BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + compare_lag=1, + ) + + def test_with_filters(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + filters=[ + FilterConfig(column="region", op="=", value="US"), + ], + ) + assert config.filters is not None + assert len(config.filters) == 1 + + def test_extra_fields_forbidden(self) -> None: + with pytest.raises(ValueError, match="Unknown field 'unknown_field'"): + BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + unknown_field="bad", + ) + + def test_with_time_grain(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + time_grain="P1M", + ) + assert config.time_grain == "P1M" + + def test_with_custom_label(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM", label="Total Sales"), + ) + assert config.metric.label == "Total Sales" + + +class TestMapBigNumberConfig: + """Test map_big_number_config function.""" + + def test_basic_total(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + form_data = map_big_number_config(config) + assert form_data["viz_type"] == "big_number_total" + assert form_data["metric"]["aggregate"] == "SUM" + assert form_data["metric"]["column"]["column_name"] == "revenue" + + def test_with_trendline(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="order_date", + show_trendline=True, + ) + form_data = map_big_number_config(config) + assert form_data["viz_type"] == "big_number" + assert form_data["show_trend_line"] is True + assert "x_axis" not in form_data + assert form_data["granularity_sqla"] == "order_date" + assert form_data["start_y_axis_at_zero"] is True + + def test_with_time_grain(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="order_date", + show_trendline=True, + time_grain="P1M", + ) + form_data = map_big_number_config(config) + assert form_data["time_grain_sqla"] == "P1M" + + def test_with_subheader(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + subheader="Year to date", + ) + form_data = map_big_number_config(config) + assert form_data["subheader"] == "Year to date" + + def test_with_y_axis_format(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + y_axis_format="$,.2f", + ) + form_data = map_big_number_config(config) + assert form_data["y_axis_format"] == "$,.2f" + + def test_with_compare_lag(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + compare_lag=7, + ) + form_data = map_big_number_config(config) + assert form_data["compare_lag"] == 7 + + def test_with_filters(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + filters=[ + FilterConfig(column="region", op="=", value="US"), + ], + ) + form_data = map_big_number_config(config) + assert "adhoc_filters" in form_data + assert len(form_data["adhoc_filters"]) == 1 + assert form_data["adhoc_filters"][0]["subject"] == "region" + assert form_data["adhoc_filters"][0]["operator"] == "==" + assert form_data["adhoc_filters"][0]["comparator"] == "US" + + def test_no_trendline_fields_for_total(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + form_data = map_big_number_config(config) + assert "x_axis" not in form_data + assert "granularity_sqla" not in form_data + assert "time_grain_sqla" not in form_data + assert "start_y_axis_at_zero" not in form_data + + +class TestMapConfigToFormDataBigNumber: + """Test map_config_to_form_data dispatch for big number.""" + + def test_dispatches_big_number(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + form_data = map_config_to_form_data(config) + assert form_data["viz_type"] == "big_number_total" + + def test_dispatches_big_number_trendline(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + ) + form_data = map_config_to_form_data(config) + assert form_data["viz_type"] == "big_number" + + +class TestGenerateChartNameBigNumber: + """Test generate_chart_name for big number configs.""" + + def test_basic_total_name(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + name = generate_chart_name(config) + assert "Big Number" in name + assert "SUM(revenue)" in name + + def test_with_trendline_name(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + ) + name = generate_chart_name(config) + assert "Big Number" in name + assert "trendline" in name + + def test_with_custom_label(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef( + name="revenue", + aggregate="SUM", + label="Total Sales", + ), + ) + name = generate_chart_name(config) + assert "Total Sales" in name + + +class TestResolveVizTypeBigNumber: + """Test _resolve_viz_type for big number configs.""" + + def test_big_number_total(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + assert _resolve_viz_type(config) == "big_number_total" + + def test_big_number_with_trendline(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + ) + assert _resolve_viz_type(config) == "big_number" + + +class TestAnalyzeChartCapabilitiesBigNumber: + """Test analyze_chart_capabilities for big number configs.""" + + def test_big_number_total_capabilities(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + result = analyze_chart_capabilities(None, config) + assert result.supports_export is True + assert result.supports_interaction is False + assert result.supports_drill_down is False + + def test_big_number_trendline_capabilities(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + ) + result = analyze_chart_capabilities(None, config) + assert result.supports_export is True + assert result.supports_interaction is False + assert result.supports_drill_down is False + + +class TestAnalyzeChartSemanticsBigNumber: + """Test analyze_chart_semantics for big number configs.""" + + def test_big_number_total_semantics(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + result = analyze_chart_semantics(None, config) + assert result is not None + assert "metric" in result.primary_insight.lower() + assert result.data_story != "" + assert len(result.recommended_actions) > 0 + + def test_big_number_trendline_semantics(self) -> None: + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + temporal_column="ds", + show_trendline=True, + ) + result = analyze_chart_semantics(None, config) + assert result is not None + assert "trend" in result.primary_insight.lower() + assert result.data_story != "" + assert len(result.recommended_actions) > 0 + + +class TestSchemaValidatorBigNumber: + """Test SchemaValidator for big number chart type.""" + + def test_valid_big_number_request(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + "metric": {"name": "revenue", "aggregate": "SUM"}, + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is True + assert request is not None + assert error is None + + def test_valid_big_number_with_trendline(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + "metric": {"name": "revenue", "aggregate": "SUM"}, + "temporal_column": "ds", + "show_trendline": True, + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is True + assert request is not None + + def test_missing_metric(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_BIG_NUMBER_METRIC" + + def test_invalid_metric_type(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + "metric": "not_a_dict", + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_BIG_NUMBER_METRIC_TYPE" + + def test_missing_metric_aggregate(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + "metric": {"name": "revenue"}, + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_BIG_NUMBER_AGGREGATE" + + def test_trendline_without_temporal(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + "metric": {"name": "revenue", "aggregate": "SUM"}, + "show_trendline": True, + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_TEMPORAL_COLUMN" + + def test_big_number_accepted_in_chart_type_check(self) -> None: + """Verify big_number passes the chart_type validation.""" + is_valid, error = SchemaValidator._pre_validate( + { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + "metric": {"name": "revenue", "aggregate": "SUM"}, + }, + } + ) + assert is_valid is True + assert error is None + + def test_invalid_chart_type_still_rejected(self) -> None: + """Ensure unknown chart types are still rejected.""" + is_valid, error = SchemaValidator._pre_validate( + { + "dataset_id": 1, + "config": {"chart_type": "nonexistent_chart"}, + } + ) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_CHART_TYPE" + + def test_big_number_with_subheader_and_format(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "big_number", + "metric": {"name": "revenue", "aggregate": "SUM"}, + "subheader": "Year to date", + "y_axis_format": "$,.2f", + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is True + assert request is not None diff --git a/tests/unit_tests/mcp_service/chart/test_chart_schemas.py b/tests/unit_tests/mcp_service/chart/test_chart_schemas.py index 3ca4793e89af..0960f3f8075d 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_schemas.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_schemas.py @@ -570,3 +570,58 @@ def test_known_aliases_not_flagged_as_unknown(self) -> None: assert config.stacked is True assert config.row_limit == 10000 assert config.group_by is not None + + +class TestColumnRefSavedMetric: + """Test ColumnRef saved_metric support.""" + + def test_saved_metric_defaults_to_false(self) -> None: + col = ColumnRef(name="revenue", aggregate="SUM") + assert col.saved_metric is False + + def test_saved_metric_flag_accepted(self) -> None: + col = ColumnRef(name="total_revenue", saved_metric=True) + assert col.saved_metric is True + assert col.name == "total_revenue" + + def test_saved_metric_clears_aggregate(self) -> None: + col = ColumnRef(name="total_revenue", saved_metric=True, aggregate="SUM") + assert col.saved_metric is True + assert col.aggregate is None + + def test_saved_metric_preserves_label(self) -> None: + col = ColumnRef(name="total_revenue", saved_metric=True, label="Revenue") + assert col.label == "Revenue" + + def test_saved_metric_in_table_config_unique_labels(self) -> None: + config = TableChartConfig( + chart_type="table", + columns=[ + ColumnRef(name="product_line"), + ColumnRef(name="total_revenue", saved_metric=True), + ], + ) + assert len(config.columns) == 2 + + def test_saved_metric_in_xy_config_unique_labels(self) -> None: + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="order_date"), + y=[ColumnRef(name="total_revenue", saved_metric=True)], + ) + assert len(config.y) == 1 + + def test_saved_metric_duplicate_label_rejected(self) -> None: + with pytest.raises(ValidationError, match="Duplicate column/metric labels"): + XYChartConfig( + chart_type="xy", + x=ColumnRef(name="order_date"), + y=[ + ColumnRef(name="total_revenue", saved_metric=True), + ColumnRef( + name="other_metric", + saved_metric=True, + label="total_revenue", + ), + ], + ) diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py b/tests/unit_tests/mcp_service/chart/test_chart_utils.py index 1a247bf1e895..293adfb09c31 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py @@ -22,8 +22,10 @@ import pytest +from superset.constants import NO_TIME_RANGE from superset.mcp_service.chart.chart_utils import ( _add_adhoc_filters, + _ensure_temporal_adhoc_filter, adhoc_filters_to_query_filters, configure_temporal_handling, create_metric_object, @@ -44,7 +46,7 @@ TableChartConfig, XYChartConfig, ) -from superset.utils.core import GenericDataType +from superset.utils.core import FilterOperator, GenericDataType class TestCreateMetricObject: @@ -55,6 +57,7 @@ def test_create_metric_object_with_aggregate(self) -> None: col = ColumnRef(name="revenue", aggregate="SUM", label="Total Revenue") result = create_metric_object(col) + assert isinstance(result, dict) assert result["aggregate"] == "SUM" assert result["column"]["column_name"] == "revenue" assert result["label"] == "Total Revenue" @@ -66,11 +69,28 @@ def test_create_metric_object_default_aggregate(self) -> None: col = ColumnRef(name="orders") result = create_metric_object(col) + assert isinstance(result, dict) assert result["aggregate"] == "SUM" assert result["column"]["column_name"] == "orders" assert result["label"] == "SUM(orders)" assert result["optionName"] == "metric_orders" + def test_create_metric_object_saved_metric_returns_string(self) -> None: + """Test that saved metrics return a plain string metric name""" + col = ColumnRef(name="total_revenue", saved_metric=True) + result = create_metric_object(col) + + assert result == "total_revenue" + assert isinstance(result, str) + + def test_create_metric_object_saved_metric_ignores_aggregate(self) -> None: + """Test that saved metrics ignore aggregate even if somehow set""" + col = ColumnRef(name="total_revenue", saved_metric=True, aggregate="SUM") + result = create_metric_object(col) + + # saved_metric validator clears aggregate, result is plain string + assert result == "total_revenue" + class TestMapFilterOperator: """Test map_filter_operator function""" @@ -338,6 +358,38 @@ def test_map_table_config_default_row_limit(self) -> None: assert result["row_limit"] == 1000 + def test_map_table_config_saved_metric_as_metric(self) -> None: + """Test that saved metrics are routed to metrics, not raw columns.""" + config = TableChartConfig( + chart_type="table", + columns=[ + ColumnRef(name="product_line"), + ColumnRef(name="total_revenue", saved_metric=True), + ], + ) + + result = map_table_config(config) + + assert result["query_mode"] == "aggregate" + assert result["metrics"] == ["total_revenue"] + assert "product_line" in result["groupby"] + + def test_map_table_config_saved_metric_only(self) -> None: + """Test table with only saved metrics (no raw columns).""" + config = TableChartConfig( + chart_type="table", + columns=[ + ColumnRef(name="total_revenue", saved_metric=True), + ColumnRef(name="avg_order_value", saved_metric=True), + ], + ) + + result = map_table_config(config) + + assert result["query_mode"] == "aggregate" + assert result["metrics"] == ["total_revenue", "avg_order_value"] + assert "all_columns" not in result + class TestAddAdhocFilters: """Test _add_adhoc_filters helper function""" @@ -615,10 +667,13 @@ def test_map_xy_config_with_filters(self, mock_is_temporal) -> None: result = map_xy_config(config) assert "adhoc_filters" in result - assert len(result["adhoc_filters"]) == 1 + # User filter + auto-added TEMPORAL_RANGE filter for temporal x-axis + assert len(result["adhoc_filters"]) == 2 assert result["adhoc_filters"][0]["subject"] == "region" assert result["adhoc_filters"][0]["operator"] == "==" assert result["adhoc_filters"][0]["comparator"] == "US" + assert result["adhoc_filters"][1]["operator"] == "TEMPORAL_RANGE" + assert result["adhoc_filters"][1]["subject"] == "date" @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") def test_map_xy_config_row_limit(self, mock_is_temporal) -> None: @@ -651,6 +706,44 @@ def test_map_xy_config_default_row_limit(self, mock_is_temporal) -> None: assert result["row_limit"] == 10000 + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") + def test_map_xy_config_saved_metric(self, mock_is_temporal: Any) -> None: + """Test XY config with saved metric emits string in metrics list""" + mock_is_temporal.return_value = True + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="order_date"), + y=[ColumnRef(name="total_revenue", saved_metric=True)], + kind="line", + ) + + result = map_xy_config(config, dataset_id=1) + + assert result["metrics"] == ["total_revenue"] + + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") + def test_map_xy_config_mixed_saved_and_adhoc_metrics( + self, mock_is_temporal: Any + ) -> None: + """Test XY config with both saved and ad-hoc metrics""" + mock_is_temporal.return_value = True + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="order_date"), + y=[ + ColumnRef(name="total_revenue", saved_metric=True), + ColumnRef(name="quantity", aggregate="SUM"), + ], + kind="line", + ) + + result = map_xy_config(config, dataset_id=1) + + assert len(result["metrics"]) == 2 + assert result["metrics"][0] == "total_revenue" + assert isinstance(result["metrics"][1], dict) + assert result["metrics"][1]["aggregate"] == "SUM" + class TestMapConfigToFormData: """Test map_config_to_form_data function""" @@ -1196,16 +1289,18 @@ class TestConfigureTemporalHandling: """Test configure_temporal_handling function""" def test_temporal_column_with_time_grain(self) -> None: - """Test temporal column sets time_grain_sqla""" - form_data: dict[str, Any] = {} + """Test temporal column sets time_grain_sqla and granularity_sqla""" + form_data: dict[str, Any] = {"x_axis": "order_date"} configure_temporal_handling(form_data, x_is_temporal=True, time_grain="P1M") assert form_data["time_grain_sqla"] == "P1M" + assert form_data["granularity_sqla"] == "order_date" def test_temporal_column_without_time_grain(self) -> None: - """Test temporal column without time_grain doesn't set time_grain_sqla""" - form_data: dict[str, Any] = {} + """Test temporal column sets granularity_sqla but not time_grain_sqla""" + form_data: dict[str, Any] = {"x_axis": "order_date"} configure_temporal_handling(form_data, x_is_temporal=True, time_grain=None) assert "time_grain_sqla" not in form_data + assert form_data["granularity_sqla"] == "order_date" def test_non_temporal_column_sets_categorical_config(self) -> None: """Test non-temporal column sets categorical configuration""" @@ -1267,7 +1362,16 @@ def test_temporal_column_allows_time_grain(self, mock_is_temporal) -> None: assert result["x_axis"] == "created_at" assert result["time_grain_sqla"] == "P1W" + assert result["granularity_sqla"] == "created_at" assert "x_axis_sort_series_type" not in result + # Temporal x-axis should have a TEMPORAL_RANGE adhoc filter + temporal_filters = [ + f + for f in result.get("adhoc_filters", []) + if f.get("operator") == "TEMPORAL_RANGE" + ] + assert len(temporal_filters) == 1 + assert temporal_filters[0]["subject"] == "created_at" @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") def test_non_temporal_ignores_time_grain_param(self, mock_is_temporal) -> None: @@ -1289,6 +1393,112 @@ def test_non_temporal_ignores_time_grain_param(self, mock_is_temporal) -> None: assert result["x_axis_sort_series_type"] == "name" +class TestEnsureTemporalAdhocFilter: + """Test _ensure_temporal_adhoc_filter helper and its integration in map_xy_config""" + + def test_adds_filter_to_empty_form_data(self) -> None: + """Test adds TEMPORAL_RANGE filter when no adhoc_filters exist""" + form_data: dict[str, Any] = {} + _ensure_temporal_adhoc_filter(form_data, "order_date") + + assert len(form_data["adhoc_filters"]) == 1 + f = form_data["adhoc_filters"][0] + assert f["operator"] == FilterOperator.TEMPORAL_RANGE.value + assert f["subject"] == "order_date" + assert f["comparator"] == NO_TIME_RANGE + assert f["expressionType"] == "SIMPLE" + assert f["clause"] == "WHERE" + + def test_appends_to_existing_filters(self) -> None: + """Test appends temporal filter after existing user filters""" + form_data: dict[str, Any] = { + "adhoc_filters": [ + {"subject": "region", "operator": "==", "comparator": "US"} + ] + } + _ensure_temporal_adhoc_filter(form_data, "order_date") + + assert len(form_data["adhoc_filters"]) == 2 + assert form_data["adhoc_filters"][0]["subject"] == "region" + assert ( + form_data["adhoc_filters"][1]["operator"] + == FilterOperator.TEMPORAL_RANGE.value + ) + + def test_does_not_duplicate_existing_temporal_filter(self) -> None: + """Test skips adding if a TEMPORAL_RANGE filter already exists for the column""" + form_data: dict[str, Any] = { + "adhoc_filters": [ + { + "subject": "order_date", + "operator": FilterOperator.TEMPORAL_RANGE.value, + "comparator": "Last 7 days", + } + ] + } + _ensure_temporal_adhoc_filter(form_data, "order_date") + + # Should still be just 1 filter (no duplicate) + assert len(form_data["adhoc_filters"]) == 1 + + def test_adds_filter_for_different_column(self) -> None: + """Test adds filter when existing temporal filter is on a different column""" + form_data: dict[str, Any] = { + "adhoc_filters": [ + { + "subject": "created_at", + "operator": FilterOperator.TEMPORAL_RANGE.value, + "comparator": NO_TIME_RANGE, + } + ] + } + _ensure_temporal_adhoc_filter(form_data, "order_date") + + assert len(form_data["adhoc_filters"]) == 2 + + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") + def test_temporal_x_axis_adds_filter_in_map_xy(self, mock_is_temporal) -> None: + """Test map_xy_config adds TEMPORAL_RANGE filter for temporal x-axis""" + mock_is_temporal.return_value = True + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="order_date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], + kind="bar", + ) + + result = map_xy_config(config, dataset_id=123) + + temporal_filters = [ + f + for f in result.get("adhoc_filters", []) + if f.get("operator") == FilterOperator.TEMPORAL_RANGE.value + ] + assert len(temporal_filters) == 1 + assert temporal_filters[0]["subject"] == "order_date" + assert temporal_filters[0]["comparator"] == NO_TIME_RANGE + + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") + def test_non_temporal_x_axis_no_temporal_filter(self, mock_is_temporal) -> None: + """Test non-temporal x-axis skips TEMPORAL_RANGE filter""" + mock_is_temporal.return_value = False + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="year"), + y=[ColumnRef(name="sales", aggregate="SUM")], + kind="bar", + ) + + result = map_xy_config(config, dataset_id=123) + + temporal_filters = [ + f + for f in result.get("adhoc_filters", []) + if f.get("operator") == FilterOperator.TEMPORAL_RANGE.value + ] + assert len(temporal_filters) == 0 + + class TestFilterConfigValidation: """Test FilterConfig validation for new operators""" diff --git a/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py b/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py index c7fa505ac931..c5ef6469eccc 100644 --- a/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py +++ b/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py @@ -28,6 +28,7 @@ ChartFilter, ListChartsRequest, ) +from superset.mcp_service.constants import MAX_PAGE_SIZE @pytest.fixture @@ -133,6 +134,19 @@ def test_invalid_page_number(self): with pytest.raises(ValueError, match="Input should be greater than 0"): ListChartsRequest(page_size=0) + def test_page_size_exceeds_max(self): + """Test that page_size over MAX_PAGE_SIZE raises validation error.""" + with pytest.raises( + ValueError, + match=f"Input should be less than or equal to {MAX_PAGE_SIZE}", + ): + ListChartsRequest(page_size=MAX_PAGE_SIZE + 1) + + def test_page_size_at_max(self): + """Test that page_size at MAX_PAGE_SIZE is accepted.""" + request = ListChartsRequest(page_size=MAX_PAGE_SIZE) + assert request.page_size == MAX_PAGE_SIZE + def test_filter_validation(self): """Test that filter validation works correctly.""" # Valid filter diff --git a/tests/unit_tests/mcp_service/chart/validation/test_column_name_normalization.py b/tests/unit_tests/mcp_service/chart/validation/test_column_name_normalization.py index 8c68f738ed17..ab663ebc811e 100644 --- a/tests/unit_tests/mcp_service/chart/validation/test_column_name_normalization.py +++ b/tests/unit_tests/mcp_service/chart/validation/test_column_name_normalization.py @@ -679,3 +679,53 @@ def test_group_by_matches_filter_after_normalization( assert normalized.group_by is not None assert normalized.filters is not None assert normalized.group_by[0].name == normalized.filters[0].column == "AIRLINE" + + +class TestValidateSavedMetrics: + """Test that saved_metric refs are validated against dataset metrics.""" + + def test_valid_saved_metric_passes( + self, mock_dataset_context: DatasetContext + ) -> None: + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="OrderDate"), + y=[ColumnRef(name="TotalRevenue", saved_metric=True)], + ) + is_valid, error = DatasetValidator.validate_against_dataset( + config, dataset_id=18, dataset_context=mock_dataset_context + ) + assert is_valid + assert error is None + + def test_column_name_as_saved_metric_fails( + self, mock_dataset_context: DatasetContext + ) -> None: + """A regular column marked as saved_metric should be rejected.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="OrderDate"), + y=[ColumnRef(name="Sales", saved_metric=True)], + ) + is_valid, error = DatasetValidator.validate_against_dataset( + config, dataset_id=18, dataset_context=mock_dataset_context + ) + assert not is_valid + assert error is not None + assert error.error_code == "INVALID_SAVED_METRIC" + + def test_nonexistent_saved_metric_fails( + self, mock_dataset_context: DatasetContext + ) -> None: + """A nonexistent saved metric should produce a specific error.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="OrderDate"), + y=[ColumnRef(name="nonexistent_metric", saved_metric=True)], + ) + is_valid, error = DatasetValidator.validate_against_dataset( + config, dataset_id=18, dataset_context=mock_dataset_context + ) + assert not is_valid + assert error is not None + assert error.error_code == "INVALID_SAVED_METRIC" diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py index 02eebddeb009..71e4b83b5ec3 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py @@ -20,7 +20,7 @@ """ import logging -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest from fastmcp import Client @@ -133,8 +133,7 @@ def _setup_generate_dashboard_mocks( The tool creates dashboards directly via db.session (bypassing CreateDashboardCommand) and re-queries user/charts in the tool's - own session. The re-fetch uses DashboardDAO.find_by_id() with - query_options for eager loading of slice relationships. + own session. This helper wires up the mock chain for that path. """ mock_user = Mock() mock_user.id = 1 @@ -144,8 +143,8 @@ def _setup_generate_dashboard_mocks( mock_user.email = "admin@example.com" mock_user.active = True - mock_query = MagicMock() - mock_filter = MagicMock() + mock_query = Mock() + mock_filter = Mock() mock_query.filter.return_value = mock_filter mock_query.filter_by.return_value = mock_filter mock_filter.order_by.return_value = mock_filter @@ -154,7 +153,6 @@ def _setup_generate_dashboard_mocks( mock_db_session.query.return_value = mock_query mock_dashboard_cls.return_value = dashboard - # DashboardDAO.find_by_id is used for the re-fetch with eager loading mock_find_by_id.return_value = dashboard @@ -558,15 +556,15 @@ async def test_add_chart_to_dashboard_basic( _mock_chart(id=20), _mock_chart(id=30), ] + # First call: initial validation returns original dashboard + # Second call: re-fetch after update returns updated dashboard + mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] + mock_chart = _mock_chart(id=30, slice_name="New Chart") mock_db_session.get.return_value = mock_chart mock_update_command.return_value.run.return_value = updated_dashboard - # First DAO call returns initial dashboard (validation), - # second DAO call returns updated dashboard (re-fetch with eager loading) - mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] - request = {"dashboard_id": 1, "chart_id": 30} async with Client(mcp_server) as client: @@ -702,6 +700,8 @@ async def test_add_chart_empty_dashboard( mock_dashboard = _mock_dashboard(id=2) mock_dashboard.slices = [] mock_dashboard.position_json = "{}" + mock_find_dashboard.return_value = mock_dashboard + mock_chart = _mock_chart(id=15) mock_db_session.get.return_value = mock_chart @@ -709,10 +709,6 @@ async def test_add_chart_empty_dashboard( updated_dashboard.slices = [_mock_chart(id=15)] mock_update_command.return_value.run.return_value = updated_dashboard - # First DAO call returns initial dashboard (validation), - # second returns updated dashboard (re-fetch) - mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] - request = {"dashboard_id": 2, "chart_id": 15} async with Client(mcp_server) as client: @@ -820,8 +816,8 @@ async def test_add_chart_to_tabbed_dashboard( updated_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=25)] mock_update_command.return_value.run.return_value = updated_dashboard - # First DAO call returns initial dashboard (validation), - # second returns updated dashboard (re-fetch) + # side_effect: first call returns initial dashboard (validation), + # second call returns updated dashboard (re-fetch after update) mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] request = {"dashboard_id": 3, "chart_id": 25} @@ -922,8 +918,8 @@ async def test_add_chart_to_specific_tab_by_name( updated_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=30)] mock_update_command.return_value.run.return_value = updated_dashboard - # First DAO call returns initial dashboard (validation), - # second returns updated dashboard (re-fetch) + # side_effect: first call returns initial dashboard (validation), + # second call returns updated dashboard (re-fetch after update) mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] request = {"dashboard_id": 3, "chart_id": 30, "target_tab": "Customers"} @@ -954,6 +950,118 @@ async def test_add_chart_to_specific_tab_by_name( assert "TAB-tab2" in chart_parents assert "TAB-tab1" not in chart_parents + @patch("superset.commands.dashboard.update.UpdateDashboardCommand") + @patch("superset.daos.dashboard.DashboardDAO.find_by_id") + @patch("superset.db.session") + @pytest.mark.asyncio + async def test_add_chart_to_tabbed_dashboard_tabs_under_root( + self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server + ): + """Test adding chart when TABS are under ROOT_ID (real-world layout). + + Real Superset dashboards place TABS directly under ROOT_ID with an + empty GRID_ID, unlike test fixtures that place TABS under GRID_ID. + The tool must NOT inject GRID_ID into ROOT_ID.children alongside + TABS, as the frontend hides non-TABS content when a TABS container + is a ROOT_ID child. + """ + mock_dashboard = _mock_dashboard(id=7, title="COVID Vaccine Dashboard") + mock_dashboard.slices = [_mock_chart(id=10)] + mock_dashboard.position_json = json.dumps( + { + "ROOT_ID": { + "children": ["TABS-wUKya7eQ0Z"], + "id": "ROOT_ID", + "type": "ROOT", + }, + "GRID_ID": { + "children": [], + "id": "GRID_ID", + "parents": ["ROOT_ID"], + "type": "GRID", + }, + "TABS-wUKya7eQ0Z": { + "children": ["TAB-BCIJF4NvgQ", "TAB-kl2Hkh2IR"], + "id": "TABS-wUKya7eQ0Z", + "parents": ["ROOT_ID"], + "type": "TABS", + }, + "TAB-BCIJF4NvgQ": { + "children": ["ROW-existing"], + "id": "TAB-BCIJF4NvgQ", + "meta": {"text": "Vaccine Candidates"}, + "parents": ["ROOT_ID", "TABS-wUKya7eQ0Z"], + "type": "TAB", + }, + "TAB-kl2Hkh2IR": { + "children": [], + "id": "TAB-kl2Hkh2IR", + "meta": {"text": "Doses Administered"}, + "parents": ["ROOT_ID", "TABS-wUKya7eQ0Z"], + "type": "TAB", + }, + "ROW-existing": { + "children": ["CHART-10"], + "id": "ROW-existing", + "meta": {"background": "BACKGROUND_TRANSPARENT"}, + "parents": [ + "ROOT_ID", + "TABS-wUKya7eQ0Z", + "TAB-BCIJF4NvgQ", + ], + "type": "ROW", + }, + "CHART-10": { + "id": "CHART-10", + "type": "CHART", + "parents": [ + "ROOT_ID", + "TABS-wUKya7eQ0Z", + "TAB-BCIJF4NvgQ", + "ROW-existing", + ], + }, + "DASHBOARD_VERSION_KEY": "v2", + } + ) + mock_chart = _mock_chart(id=91, slice_name="Vaccines by Stage") + mock_db_session.get.return_value = mock_chart + + updated_dashboard = _mock_dashboard(id=7, title="COVID Vaccine Dashboard") + updated_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=91)] + mock_update_command.return_value.run.return_value = updated_dashboard + + mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] + + request = {"dashboard_id": 7, "chart_id": 91} + + async with Client(mcp_server) as client: + result = await client.call_tool( + "add_chart_to_existing_dashboard", {"request": request} + ) + + assert result.structured_content["error"] is None + + call_args = mock_update_command.call_args[0][1] + layout = json.loads(call_args["position_json"]) + + row_key = result.structured_content["position"]["row_key"] + assert row_key in layout + + # Chart must be inside the first tab, not GRID_ID + assert row_key in layout["TAB-BCIJF4NvgQ"]["children"] + assert row_key not in layout["GRID_ID"]["children"] + + # GRID_ID must NOT be added to ROOT_ID.children alongside TABS + assert "GRID_ID" not in layout["ROOT_ID"]["children"] + assert layout["ROOT_ID"]["children"] == ["TABS-wUKya7eQ0Z"] + + # Parent chain must include the tab hierarchy, not GRID_ID + chart_parents = layout["CHART-91"]["parents"] + assert "TABS-wUKya7eQ0Z" in chart_parents + assert "TAB-BCIJF4NvgQ" in chart_parents + assert "GRID_ID" not in chart_parents + @patch("superset.commands.dashboard.update.UpdateDashboardCommand") @patch("superset.daos.dashboard.DashboardDAO.find_by_id") @patch("superset.db.session") @@ -992,6 +1100,8 @@ async def test_add_chart_dashboard_with_nanoid_rows( "DASHBOARD_VERSION_KEY": "v2", } ) + mock_find_dashboard.return_value = mock_dashboard + mock_chart = _mock_chart(id=50, slice_name="New Nanoid Chart") mock_db_session.get.return_value = mock_chart @@ -999,10 +1109,6 @@ async def test_add_chart_dashboard_with_nanoid_rows( updated_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=50)] mock_update_command.return_value.run.return_value = updated_dashboard - # First DAO call returns initial dashboard (validation), - # second returns updated dashboard (re-fetch) - mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] - request = {"dashboard_id": 4, "chart_id": 50} async with Client(mcp_server) as client: @@ -1117,6 +1223,28 @@ def test_find_tab_insert_target_unmatched_falls_back_to_first(self): _find_tab_insert_target(layout, target_tab="Nonexistent Tab") == "TAB-first" ) + def test_find_tab_insert_target_tabs_under_root(self): + """Test _find_tab_insert_target when TABS are under ROOT_ID (real layout).""" + layout = { + "ROOT_ID": {"children": ["TABS-xxx"], "type": "ROOT"}, + "GRID_ID": {"children": [], "type": "GRID", "parents": ["ROOT_ID"]}, + "TABS-xxx": {"children": ["TAB-a", "TAB-b"], "type": "TABS"}, + "TAB-a": {"children": [], "type": "TAB", "meta": {"text": "Overview"}}, + "TAB-b": {"children": [], "type": "TAB", "meta": {"text": "Details"}}, + } + assert _find_tab_insert_target(layout) == "TAB-a" + + def test_find_tab_insert_target_tabs_under_root_by_name(self): + """Test _find_tab_insert_target matches tab name when TABS under ROOT_ID.""" + layout = { + "ROOT_ID": {"children": ["TABS-xxx"], "type": "ROOT"}, + "GRID_ID": {"children": [], "type": "GRID", "parents": ["ROOT_ID"]}, + "TABS-xxx": {"children": ["TAB-a", "TAB-b"], "type": "TABS"}, + "TAB-a": {"children": [], "type": "TAB", "meta": {"text": "Overview"}}, + "TAB-b": {"children": [], "type": "TAB", "meta": {"text": "Details"}}, + } + assert _find_tab_insert_target(layout, target_tab="Details") == "TAB-b" + def test_find_tab_insert_target_no_grid(self): """Test _find_tab_insert_target with missing GRID_ID.""" assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None @@ -1174,6 +1302,56 @@ def test_ensure_layout_structure_adds_to_tab(self): assert "ROW-new" in layout["TAB-first"]["children"] assert "ROW-new" not in layout["GRID_ID"]["children"] + def test_ensure_layout_structure_tabs_under_root_no_grid_added(self): + """Test _ensure_layout_structure does NOT add GRID_ID to ROOT_ID + when TABS already exists as a ROOT_ID child. + + Real Superset tabbed dashboards place TABS under ROOT_ID, not + GRID_ID. Adding GRID_ID as a sibling of TABS confuses the + frontend and makes charts invisible. + """ + layout = { + "ROOT_ID": {"children": ["TABS-xxx"], "type": "ROOT"}, + "GRID_ID": {"children": [], "type": "GRID", "parents": ["ROOT_ID"]}, + "TABS-xxx": { + "children": ["TAB-a", "TAB-b"], + "type": "TABS", + "parents": ["ROOT_ID"], + }, + "TAB-a": { + "children": ["ROW-existing"], + "type": "TAB", + "meta": {"text": "Overview"}, + "parents": ["ROOT_ID", "TABS-xxx"], + }, + "TAB-b": { + "children": [], + "type": "TAB", + "meta": {"text": "Details"}, + "parents": ["ROOT_ID", "TABS-xxx"], + }, + } + _ensure_layout_structure(layout, "ROW-new", "TAB-a") + + # Row added to the correct tab + assert "ROW-new" in layout["TAB-a"]["children"] + # GRID_ID must NOT be injected into ROOT_ID alongside TABS + assert "GRID_ID" not in layout["ROOT_ID"]["children"] + assert layout["ROOT_ID"]["children"] == ["TABS-xxx"] + + def test_ensure_layout_structure_no_tabs_adds_grid_to_root(self): + """Test _ensure_layout_structure still adds GRID_ID to ROOT_ID + when the dashboard has no tabs (non-tabbed dashboard regression check). + """ + layout = { + "ROOT_ID": {"children": [], "type": "ROOT"}, + "GRID_ID": {"children": [], "type": "GRID", "parents": ["ROOT_ID"]}, + } + _ensure_layout_structure(layout, "ROW-new", "GRID_ID") + + assert "GRID_ID" in layout["ROOT_ID"]["children"] + assert "ROW-new" in layout["GRID_ID"]["children"] + class TestGenerateTitleFromCharts: """Tests for _generate_title_from_charts helper.""" @@ -1228,14 +1406,7 @@ def test_long_title_is_truncated(self): class TestDashboardSerializationEagerLoading: - """Tests for eager loading fix in dashboard serialization paths. - - The re-fetch uses DashboardDAO.find_by_id() with query_options for - eager loading. A try/except around the DAO call handles "Can't - reconnect until invalid transaction is rolled back" errors in - multi-tenant environments by rolling back and falling back to the - original dashboard object. - """ + """Tests for eager loading fix in dashboard serialization paths.""" @patch("superset.models.dashboard.Dashboard") @patch("superset.daos.dashboard.DashboardDAO.find_by_id") @@ -1244,106 +1415,94 @@ class TestDashboardSerializationEagerLoading: async def test_generate_dashboard_refetches_via_dao( self, mock_db_session, mock_find_by_id, mock_dashboard_cls, mcp_server ): - """generate_dashboard re-fetches dashboard via DashboardDAO.find_by_id() + """generate_dashboard re-fetches dashboard via DashboardDAO.find_by_id with eager-loaded slice relationships before serialization.""" - charts = [_mock_chart(id=1, slice_name="Chart 1")] - dashboard = _mock_dashboard(id=10, title="Refetch Test") + charts = [_mock_chart(id=1, slice_name="Refetched Chart")] + refetched_dashboard = _mock_dashboard(id=10) + refetched_dashboard.slices = charts + _setup_generate_dashboard_mocks( - mock_db_session, mock_find_by_id, mock_dashboard_cls, charts, dashboard + mock_db_session, + mock_find_by_id, + mock_dashboard_cls, + charts, + refetched_dashboard, ) - request = {"chart_ids": [1], "dashboard_title": "Refetch Test"} + request = {"chart_ids": [1]} + async with Client(mcp_server) as client: result = await client.call_tool("generate_dashboard", {"request": request}) - assert result.structured_content["error"] is None - # Verify DashboardDAO.find_by_id was called for re-fetch - mock_find_by_id.assert_called() + assert result.structured_content["error"] is None + # Verify DashboardDAO.find_by_id was called for re-fetch + mock_find_by_id.assert_called() - @patch("superset.models.dashboard.Dashboard") + @patch("superset.commands.dashboard.update.UpdateDashboardCommand") @patch("superset.daos.dashboard.DashboardDAO.find_by_id") @patch("superset.db.session") @pytest.mark.asyncio - async def test_generate_dashboard_refetch_sqlalchemy_error_rollback( - self, mock_db_session, mock_find_by_id, mock_dashboard_cls, mcp_server + async def test_add_chart_refetches_dashboard_via_dao( + self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server ): - """When the DAO re-fetch raises SQLAlchemyError, the session is - rolled back and a minimal response is returned with only scalar - attributes (no owners/tags/charts that would trigger lazy-loading).""" - from sqlalchemy.exc import SQLAlchemyError + """add_chart_to_existing_dashboard re-fetches dashboard via + DashboardDAO.find_by_id with eager-loaded slice relationships.""" + mock_dashboard = _mock_dashboard(id=1) + mock_dashboard.slices = [] + mock_dashboard.position_json = "{}" - charts = [_mock_chart(id=1, slice_name="Chart 1")] - dashboard = _mock_dashboard(id=10, title="Rollback Test") - _setup_generate_dashboard_mocks( - mock_db_session, mock_find_by_id, mock_dashboard_cls, charts, dashboard - ) - # Make the DAO re-fetch raise SQLAlchemyError - mock_find_by_id.side_effect = SQLAlchemyError("session error") + mock_chart = _mock_chart(id=5, slice_name="New Chart") + mock_db_session.get.return_value = mock_chart + + updated_dashboard = _mock_dashboard(id=1) + updated_dashboard.slices = [mock_chart] + mock_update_command.return_value.run.return_value = updated_dashboard + + # side_effect: first call returns initial dashboard (validation), + # second call returns updated dashboard (re-fetch with eager loading) + mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard] + + request = {"dashboard_id": 1, "chart_id": 5} - request = {"chart_ids": [1], "dashboard_title": "Rollback Test"} async with Client(mcp_server) as client: - result = await client.call_tool("generate_dashboard", {"request": request}) + result = await client.call_tool( + "add_chart_to_existing_dashboard", {"request": request} + ) - data = result.structured_content - assert data["error"] is None - mock_db_session.rollback.assert_called() - # Minimal response should have scalar fields - dash = data["dashboard"] - assert dash["id"] == 10 - assert dash["dashboard_title"] == "Rollback Test" - assert "/superset/dashboard/10/" in data["dashboard_url"] - # Relationship fields should be empty (defaults) - assert dash["owners"] == [] - assert dash["tags"] == [] - assert dash["charts"] == [] + assert result.structured_content["error"] is None + # DashboardDAO.find_by_id called twice: validation + re-fetch + assert mock_find_dashboard.call_count == 2 @patch("superset.commands.dashboard.update.UpdateDashboardCommand") @patch("superset.daos.dashboard.DashboardDAO.find_by_id") @patch("superset.db.session") @pytest.mark.asyncio - async def test_add_chart_refetch_sqlalchemy_error_rollback( + async def test_add_chart_falls_back_on_refetch_failure( self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server ): - """When the DAO re-fetch raises SQLAlchemyError after adding a chart, - the session is rolled back and a minimal response is returned with - only scalar attributes and position info.""" - from sqlalchemy.exc import SQLAlchemyError - - mock_dashboard = _mock_dashboard(id=1, title="Dashboard") + """add_chart_to_existing_dashboard falls back to original dashboard + if DashboardDAO.find_by_id returns None on re-fetch.""" + mock_dashboard = _mock_dashboard(id=1) mock_dashboard.slices = [] mock_dashboard.position_json = "{}" - mock_chart = _mock_chart(id=15) + mock_chart = _mock_chart(id=5, slice_name="New Chart") mock_db_session.get.return_value = mock_chart - updated = _mock_dashboard(id=1, title="Dashboard") - updated.slices = [_mock_chart(id=15)] - mock_update_command.return_value.run.return_value = updated + updated_dashboard = _mock_dashboard(id=1) + updated_dashboard.slices = [mock_chart] + mock_update_command.return_value.run.return_value = updated_dashboard + + # side_effect: first call returns dashboard (validation), + # second call returns None (re-fetch fails, should fall back) + mock_find_dashboard.side_effect = [mock_dashboard, None] - # First call returns dashboard (validation), second raises (re-fetch) - mock_find_dashboard.side_effect = [ - mock_dashboard, - SQLAlchemyError("session error"), - ] + request = {"dashboard_id": 1, "chart_id": 5} - request = {"dashboard_id": 1, "chart_id": 15} async with Client(mcp_server) as client: result = await client.call_tool( "add_chart_to_existing_dashboard", {"request": request} ) - data = result.structured_content - assert data["error"] is None - mock_db_session.rollback.assert_called() - # Minimal response should have scalar fields - dash = data["dashboard"] - assert dash["id"] == 1 - assert dash["dashboard_title"] == "Dashboard" - assert "/superset/dashboard/1/" in data["dashboard_url"] - # Position info should still be returned - assert data["position"] is not None - assert "chart_key" in data["position"] - # Relationship fields should be empty (defaults) - assert dash["owners"] == [] - assert dash["tags"] == [] - assert dash["charts"] == [] + # Tool should still succeed using fallback dashboard + assert result.structured_content["error"] is None diff --git a/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py b/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py index 487429738637..0b63e013a8cd 100644 --- a/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py +++ b/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py @@ -1236,6 +1236,7 @@ async def test_default_columns_when_select_columns_empty( "id", "table_name", "schema", + "changed_on", "changed_on_humanized", } @@ -1319,7 +1320,13 @@ async def test_default_columns_filters_actual_response_data( dataset_item = data["datasets"][0] # Verify ONLY default columns are present in the response item - expected_keys = {"id", "table_name", "schema", "changed_on_humanized"} + expected_keys = { + "id", + "table_name", + "schema", + "changed_on", + "changed_on_humanized", + } actual_keys = set(dataset_item.keys()) # The response should only contain the default columns, NOT all columns @@ -1335,7 +1342,6 @@ async def test_default_columns_filters_actual_response_data( "description", "database_name", "changed_by", - "changed_on", "columns", "metrics", ] diff --git a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py index 0a8771e48ba6..fb8aee539b04 100644 --- a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py +++ b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py @@ -312,15 +312,19 @@ async def test_generate_scatter_chart_explore_link( ) mock_create_form_data.assert_called_once() + @patch("superset.daos.dataset.DatasetDAO.find_by_id") @patch( "superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run" ) @pytest.mark.asyncio async def test_generate_explore_link_cache_failure_fallback( - self, mock_create_form_data, mcp_server + self, mock_create_form_data, mock_find_dataset, mcp_server ): """Test fallback when form_data cache creation fails.""" - mock_create_form_data.side_effect = Exception("Cache storage failed") + mock_find_dataset.return_value = _mock_dataset(id=1) + from superset.commands.exceptions import CommandException + + mock_create_form_data.side_effect = CommandException("Cache storage failed") config = TableChartConfig( chart_type="table", columns=[ColumnRef(name="test_col")] @@ -339,16 +343,18 @@ async def test_generate_explore_link_cache_failure_fallback( == "http://localhost:9001/explore/?datasource_type=table&datasource_id=1" ) + @patch("superset.daos.dataset.DatasetDAO.find_by_id") @patch( "superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run" ) @pytest.mark.asyncio async def test_generate_explore_link_database_lock_fallback( - self, mock_create_form_data, mcp_server + self, mock_create_form_data, mock_find_dataset, mcp_server ): """Test fallback when database is locked.""" from sqlalchemy.exc import OperationalError + mock_find_dataset.return_value = _mock_dataset(id=5) mock_create_form_data.side_effect = OperationalError( "database is locked", None, None ) @@ -584,15 +590,19 @@ async def test_generate_explore_link_complex_configuration( ) mock_create_form_data.assert_called_once() + @patch("superset.daos.dataset.DatasetDAO.find_by_id") @patch( "superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run" ) @pytest.mark.asyncio async def test_fallback_url_different_datasets( - self, mock_create_form_data, mcp_server + self, mock_create_form_data, mock_find_dataset, mcp_server ): """Test fallback URLs are correct for different dataset IDs.""" - mock_create_form_data.side_effect = Exception( + mock_find_dataset.return_value = _mock_dataset(id=1) + from superset.commands.exceptions import CommandException + + mock_create_form_data.side_effect = CommandException( "Always fail for fallback testing" ) @@ -612,9 +622,13 @@ async def test_fallback_url_different_datasets( assert result.data["error"] is None assert result.data["url"] == expected_url + @patch("superset.daos.dataset.DatasetDAO.find_by_id") @pytest.mark.asyncio - async def test_generate_explore_link_tool_exception_handling(self, mcp_server): + async def test_generate_explore_link_tool_exception_handling( + self, mock_find_dataset, mcp_server + ): """Test that tool-level exceptions are properly handled and return error.""" + mock_find_dataset.return_value = _mock_dataset(id=1) import sys # Get the actual module object from sys.modules (not via __init__.py which @@ -708,6 +722,55 @@ async def test_generate_explore_link_returns_form_data( # Verify datasource field format: "{dataset_id}__table" assert result.data["form_data"].get("datasource") == "1__table" + @patch("superset.daos.dataset.DatasetDAO.find_by_id") + @pytest.mark.asyncio + async def test_generate_explore_link_nonexistent_dataset( + self, mock_find_dataset, mcp_server + ): + """Test nonexistent dataset_id returns error instead of broken URL.""" + mock_find_dataset.return_value = None + + config = TableChartConfig( + chart_type="table", columns=[ColumnRef(name="test_col")] + ) + request = GenerateExploreLinkRequest(dataset_id="99999", config=config) + + async with Client(mcp_server) as client: + result = await client.call_tool( + "generate_explore_link", {"request": request.model_dump()} + ) + + assert result.data["url"] == "" + assert result.data["form_data"] == {} + assert result.data["form_data_key"] is None + assert "Dataset not found: 99999" in result.data["error"] + assert "list_datasets" in result.data["error"] + + @patch("superset.daos.dataset.DatasetDAO.find_by_id") + @pytest.mark.asyncio + async def test_generate_explore_link_nonexistent_uuid_dataset( + self, mock_find_dataset, mcp_server + ): + """Test that nonexistent UUID dataset_id returns structured error.""" + mock_find_dataset.return_value = None + + config = TableChartConfig( + chart_type="table", columns=[ColumnRef(name="test_col")] + ) + request = GenerateExploreLinkRequest( + dataset_id="00000000-0000-0000-0000-000000000000", config=config + ) + + async with Client(mcp_server) as client: + result = await client.call_tool( + "generate_explore_link", {"request": request.model_dump()} + ) + + assert result.data["url"] == "" + assert result.data["form_data"] == {} + assert result.data["form_data_key"] is None + assert "Dataset not found" in result.data["error"] + class TestGenerateExploreLinkColumnNormalization: """Tests that generate_explore_link normalizes column names. @@ -816,8 +879,12 @@ async def test_filter_column_normalized_in_form_data( assert form_data["x_axis"] == "OrderDate" # filter subject normalized to match x-axis adhoc_filters = form_data.get("adhoc_filters", []) - assert len(adhoc_filters) == 1 + # User filter + auto-added TEMPORAL_RANGE for temporal x-axis + assert len(adhoc_filters) == 2 assert adhoc_filters[0]["subject"] == "OrderDate" + assert adhoc_filters[0]["operator"] == ">" + assert adhoc_filters[1]["operator"] == "TEMPORAL_RANGE" + assert adhoc_filters[1]["subject"] == "OrderDate" @patch( "superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context" diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py index b536578ca1fd..bcd66cbe20e3 100644 --- a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py +++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py @@ -237,7 +237,7 @@ async def test_execute_sql_database_not_found( mock_security_manager, # noqa: PT019 mcp_server, ): - """Test error when database is not found.""" + """Test graceful error when database is not found.""" # mock_security_manager is patched but not used (error happens first) del mock_security_manager # Silence unused variable warning mock_db.session.query.return_value.filter_by.return_value.first.return_value = ( @@ -251,8 +251,10 @@ async def test_execute_sql_database_not_found( } async with Client(mcp_server) as client: - with pytest.raises(ToolError, match="Database with ID 999 not found"): - await client.call_tool("execute_sql", {"request": request}) + result = await client.call_tool("execute_sql", {"request": request}) + data = result.structured_content + assert data["success"] is False + assert "Database with ID 999 not found" in data["error"] @patch("superset.security_manager", new_callable=MagicMock) @patch("superset.db") @@ -274,8 +276,10 @@ async def test_execute_sql_access_denied( } async with Client(mcp_server) as client: - with pytest.raises(ToolError, match="Access denied to database"): - await client.call_tool("execute_sql", {"request": request}) + result = await client.call_tool("execute_sql", {"request": request}) + data = result.structured_content + assert data["success"] is False + assert "Access denied to database" in data["error"] @patch("superset.security_manager") @patch("superset.db") diff --git a/tests/unit_tests/mcp_service/test_auth_api_key.py b/tests/unit_tests/mcp_service/test_auth_api_key.py index 131bdb57f081..e1bbf13bf43a 100644 --- a/tests/unit_tests/mcp_service/test_auth_api_key.py +++ b/tests/unit_tests/mcp_service/test_auth_api_key.py @@ -143,24 +143,19 @@ def test_no_request_context_skips_api_key_auth(app) -> None: mock_sm._extract_api_key_from_request.assert_not_called() -# -- g.user already set -> API key auth skipped (JWT precedence) -- +# -- g.user fallback when no higher-priority auth succeeds -- -@pytest.mark.usefixtures("_enable_api_keys") -def test_existing_g_user_takes_precedence(app, mock_user) -> None: - """If g.user is already set (e.g., by JWT middleware), API key auth - should not be attempted.""" - mock_sm = MagicMock() - - with app.test_request_context(headers={"Authorization": "Bearer sst_abc123"}): +@pytest.mark.usefixtures("_disable_api_keys") +def test_g_user_fallback_when_no_jwt_or_api_key(app, mock_user) -> None: + """When no JWT or API key auth succeeds and MCP_DEV_USERNAME is not set, + g.user (set by external middleware) is used as fallback.""" + with app.test_request_context(): g.user = mock_user - app.appbuilder = MagicMock() - app.appbuilder.sm = mock_sm result = get_user_from_request() assert result.username == "api_key_user" - mock_sm._extract_api_key_from_request.assert_not_called() # -- FAB version without _extract_api_key_from_request -- diff --git a/tests/unit_tests/mcp_service/test_auth_user_resolution.py b/tests/unit_tests/mcp_service/test_auth_user_resolution.py new file mode 100644 index 000000000000..9779b1717c2b --- /dev/null +++ b/tests/unit_tests/mcp_service/test_auth_user_resolution.py @@ -0,0 +1,429 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for MCP user resolution priority and stale g.user prevention.""" + +from unittest.mock import MagicMock, patch + +import pytest +from flask import g + +from superset.mcp_service.auth import ( + _resolve_user_from_jwt_context, + get_user_from_request, + mcp_auth_hook, +) +from superset.mcp_service.mcp_config import default_user_resolver + + +def _make_mock_user(username: str = "testuser") -> MagicMock: + """Create a mock User with required attributes.""" + user = MagicMock() + user.username = username + user.roles = [] + user.groups = [] + return user + + +def _make_access_token( + claims: dict[str, str] | None = None, **kwargs: str +) -> MagicMock: + """Create a mock AccessToken matching FastMCP's format.""" + token = MagicMock() + token.claims = claims or {} + token.client_id = kwargs.get("client_id", "") + token.scopes = kwargs.get("scopes", []) + # Remove auto-created attributes so getattr fallbacks work correctly + for attr in ("subject", "payload"): + if attr not in kwargs: + delattr(token, attr) + for attr in kwargs: + setattr(token, attr, kwargs[attr]) + return token + + +# -- _resolve_user_from_jwt_context -- + + +def test_jwt_context_resolves_correct_user(app) -> None: + """JWT context with valid claims resolves the correct DB user.""" + mock_user = _make_mock_user("alice") + token = _make_access_token(claims={"sub": "alice"}) + + with app.app_context(): + with ( + patch("fastmcp.server.dependencies.get_access_token", return_value=token), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + return_value=mock_user, + ), + ): + result = _resolve_user_from_jwt_context(app) + + assert result is not None + assert result.username == "alice" + + +def test_jwt_context_returns_none_when_no_token(app) -> None: + """No JWT token present returns None (fall through to next source).""" + with app.app_context(): + with patch("fastmcp.server.dependencies.get_access_token", return_value=None): + result = _resolve_user_from_jwt_context(app) + + assert result is None + + +def test_jwt_context_raises_for_unknown_user(app) -> None: + """JWT resolves a username not in DB — raises ValueError (fail closed).""" + token = _make_access_token(claims={"sub": "nonexistent"}) + + with app.app_context(): + with ( + patch("fastmcp.server.dependencies.get_access_token", return_value=token), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + return_value=None, + ), + ): + with pytest.raises(ValueError, match="not found in Superset database"): + _resolve_user_from_jwt_context(app) + + +def test_jwt_context_raises_when_no_username_in_claims(app) -> None: + """JWT present but claims have no extractable username — fails closed.""" + token = _make_access_token(claims={"iss": "some-issuer"}) + + with app.app_context(): + with patch("fastmcp.server.dependencies.get_access_token", return_value=token): + with pytest.raises(ValueError, match="no username could be extracted"): + _resolve_user_from_jwt_context(app) + + +def test_jwt_context_uses_custom_resolver(app) -> None: + """Custom MCP_USER_RESOLVER config is used when set.""" + mock_user = _make_mock_user("custom_user") + token = _make_access_token(claims={"custom_field": "custom_user"}) + custom_resolver = MagicMock(return_value="custom_user") + + with app.app_context(): + app.config["MCP_USER_RESOLVER"] = custom_resolver + try: + with ( + patch( + "fastmcp.server.dependencies.get_access_token", return_value=token + ), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + return_value=mock_user, + ), + ): + result = _resolve_user_from_jwt_context(app) + finally: + app.config.pop("MCP_USER_RESOLVER", None) + + assert result is not None + assert result.username == "custom_user" + custom_resolver.assert_called_once_with(app, token) + + +def test_jwt_context_email_fallback_lookup(app) -> None: + """When resolver returns an email, tries email lookup after username miss.""" + mock_user = _make_mock_user("alice") + token = _make_access_token(claims={"email": "alice@example.com"}) + + def _load_side_effect(username=None, email=None): + if email == "alice@example.com": + return mock_user + return None + + with app.app_context(): + with ( + patch("fastmcp.server.dependencies.get_access_token", return_value=token), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + side_effect=_load_side_effect, + ), + ): + result = _resolve_user_from_jwt_context(app) + + assert result is not None + assert result.username == "alice" + + +# -- get_user_from_request priority order -- + + +def test_jwt_takes_priority_over_stale_g_user(app) -> None: + """Core regression test: JWT user wins over stale g.user.""" + stale_user = _make_mock_user("stale_bob") + jwt_user = _make_mock_user("jwt_alice") + token = _make_access_token(claims={"sub": "jwt_alice"}) + + with app.app_context(): + g.user = stale_user + with ( + patch("fastmcp.server.dependencies.get_access_token", return_value=token), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + return_value=jwt_user, + ), + ): + result = get_user_from_request() + + assert result.username == "jwt_alice" + + +def test_dev_username_fallback_when_no_jwt(app) -> None: + """MCP_DEV_USERNAME used when no JWT context available.""" + mock_user = _make_mock_user("dev_admin") + + with app.app_context(): + app.config["MCP_DEV_USERNAME"] = "dev_admin" + try: + with ( + patch( + "fastmcp.server.dependencies.get_access_token", return_value=None + ), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + return_value=mock_user, + ), + ): + result = get_user_from_request() + finally: + app.config.pop("MCP_DEV_USERNAME", None) + + assert result.username == "dev_admin" + + +def test_g_user_fallback_when_no_jwt_and_no_dev_username(app) -> None: + """g.user used as last-resort fallback (Preset middleware compatibility).""" + preset_user = _make_mock_user("preset_user") + + with app.app_context(): + app.config.pop("MCP_DEV_USERNAME", None) + g.user = preset_user + with patch("fastmcp.server.dependencies.get_access_token", return_value=None): + result = get_user_from_request() + + assert result.username == "preset_user" + + +def test_raises_when_no_auth_source(app) -> None: + """ValueError raised when no auth source is available.""" + with app.app_context(): + app.config.pop("MCP_DEV_USERNAME", None) + g.pop("user", None) + with patch("fastmcp.server.dependencies.get_access_token", return_value=None): + with pytest.raises(ValueError, match="No authenticated user found"): + get_user_from_request() + + +def test_dev_username_not_found_raises(app) -> None: + """MCP_DEV_USERNAME configured but user not in DB raises ValueError.""" + with app.app_context(): + app.config["MCP_DEV_USERNAME"] = "ghost" + try: + with ( + patch( + "fastmcp.server.dependencies.get_access_token", return_value=None + ), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + return_value=None, + ), + ): + with pytest.raises(ValueError, match="not found"): + get_user_from_request() + finally: + app.config.pop("MCP_DEV_USERNAME", None) + + +# -- g.user clearing in mcp_auth_hook -- + + +def test_mcp_auth_hook_clears_stale_g_user(app) -> None: + """mcp_auth_hook clears g.user before setting up user context. + + Uses a side_effect that asserts g.user was cleared before user + resolution runs, so the test fails if g.pop("user") is removed. + """ + stale_user = _make_mock_user("stale") + fresh_user = _make_mock_user("fresh") + + def dummy_tool(): + """Dummy tool.""" + return g.user.username + + wrapped = mcp_auth_hook(dummy_tool) + + def _assert_cleared_then_return(): + """Verify stale g.user was cleared before returning fresh user.""" + assert not hasattr(g, "user") or g.user is None, ( + "g.user should have been cleared before get_user_from_request() " + f"but found g.user={getattr(g, 'user', '')}" + ) + return fresh_user + + with app.app_context(): + g.user = stale_user + # Explicitly mock has_request_context to False because the test + # framework's autouse app_context fixture may implicitly provide + # a request context in some CI environments. + with ( + patch("flask.has_request_context", return_value=False), + patch( + "superset.mcp_service.auth.get_user_from_request", + side_effect=lambda: _assert_cleared_then_return(), + ), + ): + result = wrapped() + + assert result == "fresh" + + +def test_mcp_auth_hook_clears_stale_g_user_async(app) -> None: + """mcp_auth_hook clears g.user before setting up user context (async). + + Uses a side_effect that asserts g.user was cleared before user + resolution runs, so the test fails if g.pop("user") is removed. + """ + import asyncio + + stale_user = _make_mock_user("stale") + fresh_user = _make_mock_user("fresh") + + async def dummy_tool(): + """Dummy tool.""" + return g.user.username + + wrapped = mcp_auth_hook(dummy_tool) + + def _assert_cleared_then_return(): + """Verify stale g.user was cleared before returning fresh user.""" + assert not hasattr(g, "user") or g.user is None, ( + "g.user should have been cleared before get_user_from_request() " + f"but found g.user={getattr(g, 'user', '')}" + ) + return fresh_user + + with app.app_context(): + g.user = stale_user + with ( + patch("flask.has_request_context", return_value=False), + patch( + "superset.mcp_service.auth.get_user_from_request", + side_effect=lambda: _assert_cleared_then_return(), + ), + ): + result = asyncio.run(wrapped()) + + assert result == "fresh" + + +def test_mcp_auth_hook_preserves_g_user_in_request_context(app) -> None: + """g.user is NOT cleared when a request context is active (middleware compat). + + Uses a side_effect that asserts g.user is still the middleware-set + user when get_user_from_request() is called, proving the hook did + NOT clear it. + """ + middleware_user = _make_mock_user("middleware_user") + + def dummy_tool(): + """Dummy tool.""" + return g.user.username + + wrapped = mcp_auth_hook(dummy_tool) + + def _assert_preserved_then_return(): + """Verify g.user was preserved (not cleared) before returning.""" + assert hasattr(g, "user"), ( + "g.user should be preserved in request context but was removed" + ) + assert g.user is middleware_user, ( + "g.user should be preserved in request context but was changed; " + f"g.user={g.user}" + ) + return middleware_user + + with app.test_request_context(): + g.user = middleware_user + with patch( + "superset.mcp_service.auth.get_user_from_request", + side_effect=lambda: _assert_preserved_then_return(), + ): + result = wrapped() + + assert result == "middleware_user" + + +# -- default_user_resolver -- + + +def test_default_resolver_extracts_sub_from_claims() -> None: + """Extracts 'sub' claim as last-resort from AccessToken.claims dict.""" + token = _make_access_token(claims={"sub": "alice"}) + assert default_user_resolver(None, token) == "alice" + + +def test_default_resolver_extracts_preferred_username() -> None: + """Extracts 'preferred_username' claim (common OIDC claim).""" + token = _make_access_token(claims={"preferred_username": "alice"}) + assert default_user_resolver(None, token) == "alice" + + +def test_default_resolver_extracts_email_from_claims() -> None: + """Falls back to 'email' claim when 'sub' is absent.""" + token = _make_access_token(claims={"email": "alice@example.com"}) + assert default_user_resolver(None, token) == "alice@example.com" + + +def test_default_resolver_extracts_username_from_claims() -> None: + """Falls back to 'username' claim.""" + token = _make_access_token(claims={"username": "alice"}) + assert default_user_resolver(None, token) == "alice" + + +def test_default_resolver_falls_back_to_subject_attr() -> None: + """Falls back to legacy .subject attribute when claims empty.""" + token = _make_access_token(claims={}, subject="legacy_user") + assert default_user_resolver(None, token) == "legacy_user" + + +def test_default_resolver_falls_back_to_client_id() -> None: + """Falls back to .client_id when claims empty and no subject.""" + token = _make_access_token(claims={}, client_id="service-account") + assert default_user_resolver(None, token) == "service-account" + + +def test_default_resolver_returns_none_for_empty_token() -> None: + """Returns None when no claims or attributes have a username.""" + token = _make_access_token(claims={}, client_id="") + assert default_user_resolver(None, token) is None + + +def test_default_resolver_preferred_username_takes_priority() -> None: + """'preferred_username' takes priority over 'sub' and 'email' in claims.""" + token = _make_access_token( + claims={ + "sub": "opaque-id-123", + "preferred_username": "alice", + "email": "alice@example.com", + } + ) + assert default_user_resolver(None, token) == "alice"