Compare commits

...

12 Commits

Author SHA1 Message Date
Evan Rusackas
47e3172da5 feat(db-engine-specs): add engine specs for PostGIS, DoltDB, TiDB, QuestDB, and Timeplus
Add database engine specs with metadata and logos for five databases:

- PostGIS: PostgreSQL-compatible spatial database extension
- DoltDB: MySQL-compatible version-controlled database
- TiDB: MySQL-compatible distributed HTAP database
- QuestDB: High-performance time-series database
- Timeplus: Streaming-first analytics platform

Also fixes MSSQL's logo reference (msql.png → mssql-server.png).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 16:33:48 -08:00
Joe Li
142b2cc425 test(e2e): add Playwright E2E tests for Chart List page (#37866)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:16:11 -08:00
Joe Li
6328e51620 test(examples): add tests for UUID threading and security bypass (#37557)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 14:12:12 -08:00
Joe Li
0d5ddb3674 feat(themes): add enhanced validation and error handling with fallback mechanisms (#37378)
Co-authored-by: Rafael Benitez <rebenitez1802@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 14:06:58 -08:00
Pat Buxton
58d245c6b0 chore(deps): Update sqlachemy-utils to 0.42.0 (#36240) 2026-02-12 12:39:06 -08:00
Jean Massucatto
dbf5e1f131 feat(theme): use IBM Plex Mono for code and numerical displays (#37366)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:32:41 -08:00
Jonathan Alberth Quispe Fuentes
88ce1425e2 fix(roles): optimize user fetching and resolve N+1 query issue (#37235) 2026-02-12 09:32:19 -08:00
Amin Ghadersohi
4dfece9ee5 feat(mcp): add event_logger instrumentation to MCP tools (#37859) 2026-02-12 16:50:20 +01:00
Amin Ghadersohi
3f64c25712 fix(mcp): Add database_name as valid filter column for list_datasets (#37865)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:47:46 +01:00
dependabot[bot]
afacca350f chore(deps-dev): bump oxlint from 1.42.0 to 1.46.0 in /superset-frontend (#37917)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-12 21:45:26 +07:00
dependabot[bot]
30ccbb2e05 chore(deps): update @types/geojson requirement from ^7946.0.10 to ^7946.0.16 in /superset-frontend/plugins/plugin-chart-cartodiagram (#37908)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-12 20:59:28 +07:00
Michael S. Molina
19ec7b48a0 fix: Conditional formatting painting empty cells (#37894) 2026-02-12 10:22:00 -03:00
94 changed files with 5269 additions and 1080 deletions

View File

@@ -24,6 +24,42 @@ assists people when migrating to a new version.
## Next
### MCP Tool Observability
MCP (Model Context Protocol) tools now include enhanced observability instrumentation for monitoring and debugging:
**Two-layer instrumentation:**
1. **Middleware layer** (`LoggingMiddleware`): Automatically logs all MCP tool calls with `duration_ms` and `success` status in the audit log (Action Log UI, logs table)
2. **Sub-operation tracking**: All 19 MCP tools include granular `event_logger.log_context()` blocks for tracking individual operations like validation, database writes, and query execution
**Action naming convention:**
- Tool-level logs: `mcp_tool_call` (via middleware)
- Sub-operation logs: `mcp.{tool_name}.{operation}` (e.g., `mcp.generate_chart.validation`, `mcp.execute_sql.query_execution`)
**Querying MCP logs:**
```sql
-- Top slowest MCP operations
SELECT action, COUNT(*) as calls, AVG(duration_ms) as avg_ms
FROM logs
WHERE action LIKE 'mcp.%'
GROUP BY action
ORDER BY avg_ms DESC
LIMIT 20;
-- MCP tool success rate
SELECT
json_extract(curated_payload, '$.tool') as tool,
COUNT(*) as total_calls,
SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) as successful,
ROUND(100.0 * SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) / COUNT(*), 2) as success_rate
FROM logs
WHERE action = 'mcp_tool_call'
GROUP BY tool
ORDER BY total_calls DESC;
```
**Security note:** Sensitive parameters (passwords, API keys, tokens) are automatically redacted in logs as `[REDACTED]`.
### Signal Cache Backend
A new `SIGNAL_CACHE_CONFIG` configuration provides a unified Redis-based backend for real-time coordination features in Superset. This backend enables:

View File

@@ -48,6 +48,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",

View File

@@ -2446,6 +2446,11 @@
resolved "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz"
integrity sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==
"@fontsource/ibm-plex-mono@^5.2.7":
version "5.2.7"
resolved "https://registry.yarnpkg.com/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz#ef5b6f052115fdf6666208a5f8a0f13fcd7ba1fd"
integrity sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==
"@fontsource/inter@^5.2.8":
version "5.2.8"
resolved "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz"

View File

@@ -99,7 +99,7 @@ dependencies = [
"simplejson>=3.15.0",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.3, <0.39",
"sqlalchemy-utils>=0.42.0, <0.43",
"sqlglot>=28.10.0, <29",
# newer pandas needs 0.9+
"tabulate>=0.9.0, <1.0",

View File

@@ -399,7 +399,7 @@ sqlalchemy==1.4.54
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-utils
sqlalchemy-utils==0.38.3
sqlalchemy-utils==0.42.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core

View File

@@ -990,7 +990,7 @@ sqlalchemy==1.4.54
# sqlalchemy-utils
sqlalchemy-bigquery==1.15.0
# via apache-superset
sqlalchemy-utils==0.38.3
sqlalchemy-utils==0.42.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -45,7 +45,7 @@ dependencies = [
"flask-appbuilder>=5.0.2,<6",
"pydantic>=2.8.0",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0",
"sqlalchemy-utils>=0.42.0",
"sqlglot>=28.10.0, <29",
"typing-extensions>=4.0.0",
]

View File

@@ -136,7 +136,7 @@
"react/jsx-no-bind": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-boolean-value": ["error", "never", { "always": [] }],
"react/jsx-no-duplicate-props": ["error", { "ignoreCase": true }],
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/jsx-pascal-case": ["error", { "allowAllCaps": true, "ignore": [] }],
"react/jsx-uses-vars": "error",

View File

@@ -28,6 +28,7 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
@@ -265,7 +266,7 @@
"lightningcss": "^1.31.1",
"mini-css-extract-plugin": "^2.10.0",
"open-cli": "^8.0.0",
"oxlint": "^1.42.0",
"oxlint": "^1.46.0",
"po2json": "^0.4.5",
"prettier": "3.8.1",
"prettier-plugin-packagejson": "^3.0.0",
@@ -4032,12 +4033,11 @@
}
}
},
"node_modules/@fontsource/fira-code": {
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz",
"integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz",
"integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==",
"license": "OFL-1.1",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
@@ -9058,10 +9058,44 @@
"@octokit/openapi-types": "^25.1.0"
}
},
"node_modules/@oxlint/darwin-arm64": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.42.0.tgz",
"integrity": "sha512-ui5CdAcDsXPQwZQEXOOSWsilJWhgj9jqHCvYBm2tDE8zfwZZuF9q58+hGKH1x5y0SV4sRlyobB2Quq6uU6EgeA==",
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.46.0.tgz",
"integrity": "sha512-vLPcE+HcZ/W/0cVA1KLuAnoUSejGougDH/fDjBFf0Q+rbBIyBNLevOhgx3AnBNAt3hcIGY7U05ISbJCKZeVa3w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.46.0.tgz",
"integrity": "sha512-b8IqCczUsirdtJ3R/be4cRm64I5pMPafMO/9xyTAZvc+R/FxZHMQuhw0iNT9hQwRn+Uo5rNAoA8QS7QurG2QeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.46.0.tgz",
"integrity": "sha512-CfC/KGnNMhI01dkfCMjquKnW4zby3kqD5o/9XA7+pgo9I4b+Nipm+JVFyZPWMNwKqLXNmi35GTLWjs9svPxlew==",
"cpu": [
"arm64"
],
@@ -9070,12 +9104,15 @@
"optional": true,
"os": [
"darwin"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/darwin-x64": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.42.0.tgz",
"integrity": "sha512-wo0M/hcpHRv7vFje99zHHqheOhVEwUOKjOgBKyi0M99xcLizv04kcSm1rTd6HSCeZgOtiJYZRVAlKhQOQw2byQ==",
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.46.0.tgz",
"integrity": "sha512-m38mKPsV3rBdWOJ4TAGZiUjWU8RGrBxsmdSeMQ0bPr/8O6CUOm/RJkPBf0GAfPms2WRVcbkfEXvIiPshAeFkeA==",
"cpu": [
"x64"
],
@@ -9084,12 +9121,66 @@
"optional": true,
"os": [
"darwin"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/linux-arm64-gnu": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.42.0.tgz",
"integrity": "sha512-j4QzfCM8ks+OyM+KKYWDiBEQsm5RCW50H1Wz16wUyoFsobJ+X5qqcJxq6HvkE07m8euYmZelyB0WqsiDoz1v8g==",
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.46.0.tgz",
"integrity": "sha512-YaFRKslSAfuMwn7ejS1/wo9jENqQigpGBjjThX+mrpmEROLYGky+zIC5xSVGRng28U92VEDVbSNJ/sguz3dUAA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.46.0.tgz",
"integrity": "sha512-Nlw+5mSZQtkg1Oj0N8ulxzG8ATpmSDz5V2DNaGhaYAVlcdR8NYSm/xTOnweOXc/UOOv3LwkPPYzqcfPhu2lEkA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.46.0.tgz",
"integrity": "sha512-d3Y5y4ukMqAGnWLMKpwqj8ftNUaac7pA0NrId4AZ77JvHzezmxEcm2gswaBw2HW8y1pnq6KDB0vEPPvpTfDLrA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.46.0.tgz",
"integrity": "sha512-jkjx+XSOPuFR+C458prQmehO+v0VK19/3Hj2mOYDF4hHUf3CzmtA4fTmQUtkITZiGHnky7Oao6JeJX24mrX7WQ==",
"cpu": [
"arm64"
],
@@ -9098,12 +9189,15 @@
"optional": true,
"os": [
"linux"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/linux-arm64-musl": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.42.0.tgz",
"integrity": "sha512-g5b1Uw7zo6yw4Ymzyd1etKzAY7xAaGA3scwB8tAp3QzuY7CYdfTwlhiLKSAKbd7T/JBgxOXAGNcLDorJyVTXcg==",
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.46.0.tgz",
"integrity": "sha512-X/aPB1rpJUdykjWSeeGIbjk6qbD8VDulgLuTSMWgr/t6m1ljcAjqHb1g49pVG9bZl55zjECgzvlpPLWnfb4FMQ==",
"cpu": [
"arm64"
],
@@ -9112,12 +9206,83 @@
"optional": true,
"os": [
"linux"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/linux-x64-gnu": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.42.0.tgz",
"integrity": "sha512-HnD99GD9qAbpV4q9iQil7mXZUJFpoBdDavfcC2CgGLPlawfcV5COzQPNwOgvPVkr7C0cBx6uNCq3S6r9IIiEIg==",
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.46.0.tgz",
"integrity": "sha512-AymyOxGWwKY2KJa8b+h8iLrYJZbWKYCjqctSc2q6uIAkYPrCsxcWlge1JP6XZ14Sa80DVMwI/QvktbytSV+xVw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.46.0.tgz",
"integrity": "sha512-PkeVdPKCDA59rlMuucsel2LjlNEpslQN5AhkMMorIJZItbbqi/0JSuACCzaiIcXYv0oNfbeQ8rbOBikv+aT6cg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.46.0.tgz",
"integrity": "sha512-snQaRLO/X+Ry/CxX1px1g8GUbmXzymdRs+/RkP2bySHWZFhFDtbLm2hA1ujX/jKlTLMJDZn4hYzFGLDwG/Rh2w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.46.0.tgz",
"integrity": "sha512-kZhDMwUe/sgDTluGao9c0Dqc1JzV6wPzfGo0l/FLQdh5Zmp39Yg1FbBsCgsJfVKmKl1fNqsHyFLTShWMOlOEhA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.46.0.tgz",
"integrity": "sha512-n5a7VtQTxHZ13cNAKQc3ziARv5bE1Fx868v/tnhZNVUjaRNYe5uiUrRJ/LZghdAzOxVuQGarjjq/q4QM2+9OPA==",
"cpu": [
"x64"
],
@@ -9126,12 +9291,15 @@
"optional": true,
"os": [
"linux"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/linux-x64-musl": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.42.0.tgz",
"integrity": "sha512-8NTe8A78HHFn+nBi+8qMwIjgv9oIBh+9zqCPNLH56ah4vKOPvbePLI6NIv9qSkmzrBuu8SB+FJ2TH/G05UzbNA==",
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.46.0.tgz",
"integrity": "sha512-KpsDU/BhdVn3iKCLxMXAOZIpO8fS0jEA5iluRoK1rhHPwKtpzEm/OCwERsu/vboMSZm66qnoTUVXRPJ8M+iKVQ==",
"cpu": [
"x64"
],
@@ -9140,12 +9308,32 @@
"optional": true,
"os": [
"linux"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/win32-arm64": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.42.0.tgz",
"integrity": "sha512-lAPS2YAuu+qFqoTNPFcNsxXjwSV0M+dOgAzzVTAN7Yo2ifj+oLOx0GsntWoM78PvQWI7Q827ZxqtU2ImBmDapA==",
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.46.0.tgz",
"integrity": "sha512-jtbqUyEXlsDlRmMtTZqNbw49+1V/WxqNAR5l0S3OEkdat9diI5I+eqq9IT+jb5cSDdszTGcXpn7S3+gUYSydxQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.46.0.tgz",
"integrity": "sha512-EE8NjpqEZPwHQVigNvdyJ11dZwWIfsfn4VeBAuiJeAdrnY4HFX27mIjJINJgP5ZdBYEFV1OWH/eb9fURCYel8w==",
"cpu": [
"arm64"
],
@@ -9154,12 +9342,32 @@
"optional": true,
"os": [
"win32"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/win32-x64": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.42.0.tgz",
"integrity": "sha512-3/KmyUOHNriL6rLpaFfm9RJxdhpXY2/Ehx9UuorJr2pUA+lrZL15FAEx/DOszYm5r10hfzj40+efAHcCilNvSQ==",
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.46.0.tgz",
"integrity": "sha512-BHyk3H/HRdXs+uImGZ/2+qCET+B8lwGHOm7m54JiJEEUWf3zYCFX/Df1SPqtozWWmnBvioxoTG1J3mPRAr8KUA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.46.0.tgz",
"integrity": "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug==",
"cpu": [
"x64"
],
@@ -9168,7 +9376,10 @@
"optional": true,
"os": [
"win32"
]
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.6.0",
@@ -14241,18 +14452,6 @@
"@types/node": "*"
}
},
"node_modules/@types/cheerio": {
"version": "0.22.35",
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.35.tgz",
"integrity": "sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chroma-js": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.5.tgz",
@@ -15494,20 +15693,6 @@
"@types/node": "*"
}
},
"node_modules/@types/webpack": {
"version": "5.28.5",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz",
"integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/node": "*",
"tapable": "^2.2.0",
"webpack": "^5"
}
},
"node_modules/@types/webpack-sources": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.3.tgz",
@@ -17604,8 +17789,7 @@
"version": "1.43.6",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.6.tgz",
"integrity": "sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg==",
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "7.4.1",
@@ -18056,29 +18240,6 @@
"node": ">=8"
}
},
"node_modules/array.prototype.filter": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.4.tgz",
"integrity": "sha512-r+mCJ7zXgXElgR4IRC+fkvNCeoaavWBs6EdCso5Tbcf+iEMKzBU/His60lt34WEZ9vlb8wDkZvQGcVI5GwkfoQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.2",
"es-array-method-boxes-properly": "^1.0.0",
"es-object-atoms": "^1.0.0",
"is-string": "^1.0.7"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
@@ -23287,15 +23448,6 @@
"node": ">=8"
}
},
"node_modules/discontinuous-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/distributions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/distributions/-/distributions-2.2.0.tgz",
@@ -23892,87 +24044,6 @@
"node": ">=4"
}
},
"node_modules/enzyme": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz",
"integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"array.prototype.flat": "^1.2.3",
"cheerio": "^1.0.0-rc.3",
"enzyme-shallow-equal": "^1.0.1",
"function.prototype.name": "^1.1.2",
"has": "^1.0.3",
"html-element-map": "^1.2.0",
"is-boolean-object": "^1.0.1",
"is-callable": "^1.1.5",
"is-number-object": "^1.0.4",
"is-regex": "^1.0.5",
"is-string": "^1.0.5",
"is-subset": "^0.1.1",
"lodash.escape": "^4.0.1",
"lodash.isequal": "^4.5.0",
"object-inspect": "^1.7.0",
"object-is": "^1.0.2",
"object.assign": "^4.1.0",
"object.entries": "^1.1.1",
"object.values": "^1.1.1",
"raf": "^3.4.1",
"rst-selector-parser": "^2.2.3",
"string.prototype.trim": "^1.2.1"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/enzyme-shallow-equal": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.7.tgz",
"integrity": "sha512-/um0GFqUXnpM9SvKtje+9Tjoz3f1fpBC3eXRFrNs8kpYn69JljciYP7KZTqM/YQbUY9KUjvKB4jo/q+L6WGGvg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"hasown": "^2.0.0",
"object-is": "^1.1.5"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/enzyme-to-json": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/enzyme-to-json/-/enzyme-to-json-3.6.2.tgz",
"integrity": "sha512-Ynm6Z6R6iwQ0g2g1YToz6DWhxVnt8Dy1ijR2zynRKxTyBGA8rCDXU3rs2Qc4OKvUvc2Qoe1bcFK6bnPs20TrTg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/cheerio": "^0.22.22",
"lodash": "^4.17.21",
"react-is": "^16.12.0"
},
"engines": {
"node": ">=6.0.0"
},
"peerDependencies": {
"enzyme": "^3.4.0"
}
},
"node_modules/enzyme-to-json/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/err-code": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
@@ -28868,22 +28939,6 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/html-element-map": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz",
"integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"array.prototype.filter": "^1.0.0",
"call-bind": "^1.0.2"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -30419,15 +30474,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-subset": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
"integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/is-symbol": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
@@ -34484,15 +34530,15 @@
}
},
"node_modules/jsdom/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -36239,15 +36285,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escape": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz",
"integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/lodash.flattendeep": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
@@ -36451,17 +36488,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -37841,15 +37867,6 @@
"integrity": "sha512-2I8/T3X/hLxB2oPHgqcNYUVdA/ZEFShT7IAujifIPMfKkNbLOqY8XCoyHCXrsdjb36dW9MwoTwBCFpXKMwNwaQ==",
"license": "MIT"
},
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"peer": true
},
"node_modules/mousetrap": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz",
@@ -37999,40 +38016,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT"
},
"node_modules/nearley": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^2.19.0",
"moo": "^0.5.0",
"railroad-diagrams": "^1.0.0",
"randexp": "0.4.6"
},
"bin": {
"nearley-railroad": "bin/nearley-railroad.js",
"nearley-test": "bin/nearley-test.js",
"nearley-unparse": "bin/nearley-unparse.js",
"nearleyc": "bin/nearleyc.js"
},
"funding": {
"type": "individual",
"url": "https://nearley.js.org/#give-to-nearley"
}
},
"node_modules/nearley/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -39411,9 +39394,9 @@
}
},
"node_modules/oxlint": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.42.0.tgz",
"integrity": "sha512-qnspC/lrp8FgKNaONLLn14dm+W5t0SSlus6V5NJpgI2YNT1tkFYZt4fBf14ESxf9AAh98WBASnW5f0gtw462Lg==",
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.46.0.tgz",
"integrity": "sha512-I9h42QDtAVsRwoueJ4PL/7qN5jFzIUXvbO4Z5ddtII92ZCiD7uiS/JW2V4viBSfGLsbZkQp3YEs6Ls4I8q+8tA==",
"dev": true,
"license": "MIT",
"bin": {
@@ -39426,14 +39409,25 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/darwin-arm64": "1.42.0",
"@oxlint/darwin-x64": "1.42.0",
"@oxlint/linux-arm64-gnu": "1.42.0",
"@oxlint/linux-arm64-musl": "1.42.0",
"@oxlint/linux-x64-gnu": "1.42.0",
"@oxlint/linux-x64-musl": "1.42.0",
"@oxlint/win32-arm64": "1.42.0",
"@oxlint/win32-x64": "1.42.0"
"@oxlint/binding-android-arm-eabi": "1.46.0",
"@oxlint/binding-android-arm64": "1.46.0",
"@oxlint/binding-darwin-arm64": "1.46.0",
"@oxlint/binding-darwin-x64": "1.46.0",
"@oxlint/binding-freebsd-x64": "1.46.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.46.0",
"@oxlint/binding-linux-arm-musleabihf": "1.46.0",
"@oxlint/binding-linux-arm64-gnu": "1.46.0",
"@oxlint/binding-linux-arm64-musl": "1.46.0",
"@oxlint/binding-linux-ppc64-gnu": "1.46.0",
"@oxlint/binding-linux-riscv64-gnu": "1.46.0",
"@oxlint/binding-linux-riscv64-musl": "1.46.0",
"@oxlint/binding-linux-s390x-gnu": "1.46.0",
"@oxlint/binding-linux-x64-gnu": "1.46.0",
"@oxlint/binding-linux-x64-musl": "1.46.0",
"@oxlint/binding-openharmony-arm64": "1.46.0",
"@oxlint/binding-win32-arm64-msvc": "1.46.0",
"@oxlint/binding-win32-ia32-msvc": "1.46.0",
"@oxlint/binding-win32-x64-msvc": "1.46.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.11.2"
@@ -41435,31 +41429,6 @@
"performance-now": "^2.1.0"
}
},
"node_modules/railroad-diagrams": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
"dev": true,
"license": "CC0-1.0",
"optional": true,
"peer": true
},
"node_modules/randexp": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"discontinuous-range": "1.0.0",
"ret": "~0.1.10"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -42886,21 +42855,6 @@
"node": ">=6"
}
},
"node_modules/react-shallow-renderer": {
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-sortable-hoc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz",
@@ -42943,31 +42897,6 @@
"react": "^16.8.3 || ^17.0.0-0 || ^18.0.0"
}
},
"node_modules/react-test-renderer": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
"integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^17.0.2",
"react-shallow-renderer": "^16.13.1",
"scheduler": "^0.20.2"
},
"peerDependencies": {
"react": "17.0.2"
}
},
"node_modules/react-test-renderer/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -44755,18 +44684,6 @@
"node": ">=8"
}
},
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.12"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -44892,19 +44809,6 @@
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rst-selector-parser": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",
"integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"dependencies": {
"lodash.flattendeep": "^4.4.0",
"nearley": "^2.7.10"
}
},
"node_modules/run-applescript": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
@@ -50952,15 +50856,15 @@
}
},
"node_modules/whatwg-url/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -52057,7 +51961,7 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"antd": "^5.26.0",
"jed": "^1.1.1",
@@ -52069,13 +51973,6 @@
"tinycolor2": "*"
}
},
"packages/superset-core/node_modules/@types/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
"dev": true,
"license": "MIT"
},
"packages/superset-ui-chart-controls": {
"name": "@superset-ui/chart-controls",
"version": "0.20.3",
@@ -52201,13 +52098,6 @@
"@types/unist": "*"
}
},
"packages/superset-ui-core/node_modules/@types/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
"dev": true,
"license": "MIT"
},
"packages/superset-ui-core/node_modules/@types/mdast": {
"version": "3.0.15",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz",
@@ -52224,12 +52114,6 @@
"dev": true,
"license": "MIT"
},
"packages/superset-ui-core/node_modules/ace-builds": {
"version": "1.43.5",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.5.tgz",
"integrity": "sha512-iH5FLBKdB7SVn9GR37UgA/tpQS8OTWIxWAuq3Ofaw+Qbc69FfPXsXd9jeW7KRG2xKpKMqBDnu0tHBrCWY5QI7A==",
"license": "BSD-3-Clause"
},
"packages/superset-ui-core/node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
@@ -53937,7 +53821,7 @@
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
"@types/geojson": "^7946.0.10",
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
"lodash": "^4.17.23"
},
@@ -54017,13 +53901,6 @@
"react-dom": "^17.0.2"
}
},
"plugins/plugin-chart-handlebars/node_modules/@types/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
"dev": true,
"license": "MIT"
},
"plugins/plugin-chart-handlebars/node_modules/just-handlebars-helpers": {
"version": "1.0.19",
"resolved": "https://registry.npmjs.org/just-handlebars-helpers/-/just-handlebars-helpers-1.0.19.tgz",

View File

@@ -109,6 +109,7 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
@@ -346,7 +347,7 @@
"lightningcss": "^1.31.1",
"mini-css-extract-plugin": "^2.10.0",
"open-cli": "^8.0.0",
"oxlint": "^1.42.0",
"oxlint": "^1.46.0",
"po2json": "^0.4.5",
"prettier": "3.8.1",
"prettier-plugin-packagejson": "^3.0.0",

View File

@@ -33,7 +33,7 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"nanoid": "^5.0.9",
"react": "^17.0.2",

View File

@@ -23,9 +23,9 @@ import '@fontsource/inter/200.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/fira-code/400.css';
import '@fontsource/fira-code/500.css';
import '@fontsource/fira-code/600.css';
import '@fontsource/ibm-plex-mono/400.css';
import '@fontsource/ibm-plex-mono/500.css';
import '@fontsource/ibm-plex-mono/600.css';
/* eslint-enable import/extensions */
import { css, useTheme, Global } from '@emotion/react';

View File

@@ -502,7 +502,7 @@ test('Theme base theme integration works with real-world Superset base theme con
colorSuccess: '#5ac189',
colorInfo: '#66bcfe',
fontFamily: "'Inter', Helvetica, Arial",
fontFamilyCode: "'Fira Code', 'Courier New', monospace",
fontFamilyCode: "'IBM Plex Mono', 'Courier New', monospace",
},
};

View File

@@ -17,4 +17,5 @@
* under the License.
*/
export { default as isBlank } from './isBlank';
export { default as logging } from './logging';

View File

@@ -0,0 +1,59 @@
/**
* 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 isBlank from './isBlank';
test('returns true for null', () => {
expect(isBlank(null)).toBe(true);
});
test('returns true for undefined', () => {
expect(isBlank(undefined)).toBe(true);
});
test('returns true for empty string', () => {
expect(isBlank('')).toBe(true);
});
test('returns true for whitespace-only strings', () => {
expect(isBlank(' ')).toBe(true);
expect(isBlank(' ')).toBe(true);
expect(isBlank('\t')).toBe(true);
expect(isBlank('\n')).toBe(true);
expect(isBlank(' \t\n ')).toBe(true);
});
test('returns false for non-empty strings', () => {
expect(isBlank('hello')).toBe(false);
expect(isBlank(' hello ')).toBe(false);
});
test('returns true for NaN', () => {
expect(isBlank(NaN)).toBe(true);
});
test('returns false for numbers', () => {
expect(isBlank(0)).toBe(false);
expect(isBlank(50)).toBe(false);
expect(isBlank(-1)).toBe(false);
});
test('returns false for booleans', () => {
expect(isBlank(true)).toBe(false);
expect(isBlank(false)).toBe(false);
});

View File

@@ -0,0 +1,28 @@
/**
* 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 { isEmpty, isNaN, isNil, isString, trim } from 'lodash';
/**
* Checks if a value is null, undefined, NaN, or a whitespace-only string.
*/
export default function isBlank(value: unknown): boolean {
return (
isNil(value) || isNaN(value) || (isString(value) && isEmpty(trim(value)))
);
}

View File

@@ -17,6 +17,8 @@
* under the License.
*/
import memoizeOne from 'memoize-one';
import { isString, isBoolean } from 'lodash';
import { isBlank } from '@apache-superset/core';
import { addAlpha, DataRecord } from '@superset-ui/core';
import {
ColorFormatters,
@@ -254,6 +256,9 @@ export const getColorFunction = (
}
return (value: number | string | boolean | null) => {
if (isBlank(value) && operator !== Comparator.IsNull) {
return undefined;
}
const compareResult = comparatorFunction(value, columnValues);
if (compareResult === false) return undefined;
const { cutoffValue, extremeValue } = compareResult;
@@ -318,11 +323,3 @@ export const getColorFormatters = memoizeOne(
[],
) ?? [],
);
function isString(value: unknown) {
return typeof value === 'string';
}
function isBoolean(value: unknown) {
return typeof value === 'boolean';
}

View File

@@ -506,6 +506,117 @@ test('getColorFunction IsNotNull', () => {
expect(colorFunction(null)).toBeUndefined();
});
test('getColorFunction returns undefined for null values on numeric comparators', () => {
const operators = [
{ operator: Comparator.LessThan, targetValue: 50 },
{ operator: Comparator.LessOrEqual, targetValue: 50 },
{ operator: Comparator.GreaterThan, targetValue: 50 },
{ operator: Comparator.GreaterOrEqual, targetValue: 50 },
{ operator: Comparator.Equal, targetValue: 50 },
{ operator: Comparator.NotEqual, targetValue: 50 },
];
operators.forEach(({ operator, targetValue }) => {
const colorFunction = getColorFunction(
{
operator,
targetValue,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
});
test('getColorFunction returns undefined for null values on Between comparators', () => {
const operators = [
Comparator.Between,
Comparator.BetweenOrEqual,
Comparator.BetweenOrLeftEqual,
Comparator.BetweenOrRightEqual,
];
operators.forEach(operator => {
const colorFunction = getColorFunction(
{
operator,
targetValueLeft: -10,
targetValueRight: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
});
test('getColorFunction returns undefined for null values on None operator', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
test('getColorFunction returns undefined for null values on string comparators', () => {
const operators = [
Comparator.BeginsWith,
Comparator.EndsWith,
Comparator.Containing,
Comparator.NotContaining,
];
operators.forEach(operator => {
const colorFunction = getColorFunction(
{
operator,
targetValue: 'test',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
});
test('getColorFunction returns undefined for empty and whitespace string values', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction('' as unknown as number)).toBeUndefined();
expect(colorFunction(' ' as unknown as number)).toBeUndefined();
expect(colorFunction('\t' as unknown as number)).toBeUndefined();
});
test('getColorFunction IsNull still matches null values', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.IsNull,
targetValue: '',
colorScheme: '#FF0000',
column: 'isMember',
},
boolValues,
);
expect(colorFunction(null)).toEqual('#FF0000FF');
expect(colorFunction(true)).toBeUndefined();
});
test('correct column config', () => {
const columnConfig = [
{

View File

@@ -42,6 +42,21 @@ test('renders SQLEditor', async () => {
});
});
test('SQLEditor uses fontFamilyCode from theme', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
const fontFamily = editorInstance?.getOption('fontFamily');
// Verify font family is set (not undefined) and contains a monospace font
expect(fontFamily).toBeDefined();
expect(fontFamily).toMatch(/mono|courier|consolas/i);
});
test('renders FullSQLEditor', async () => {
const { container } = render(<FullSQLEditor />);

View File

@@ -114,7 +114,7 @@ export function AsyncAceEditor(
defaultMode,
defaultTheme,
defaultTabSize = 2,
fontFamily = 'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
fontFamily,
placeholder,
}: AsyncAceEditorOptions = {},
) {
@@ -171,6 +171,7 @@ export function AsyncAceEditor(
ref,
) {
const token = useTheme();
const editorFontFamily = fontFamily || token.fontFamilyCode;
const langTools = acequire('ace/ext/language_tools');
const setCompleters = useCallback(
@@ -436,7 +437,7 @@ export function AsyncAceEditor(
theme={theme}
tabSize={tabSize}
defaultValue={defaultValue}
setOptions={{ fontFamily }}
setOptions={{ fontFamily: editorFontFamily }}
{...props}
/>
</>

View File

@@ -34,6 +34,11 @@ test('works with an onClick handler', () => {
expect(mockAction).toHaveBeenCalled();
});
test('renders with monospace prop', () => {
const { getByText } = render(<Label monospace>monospace text</Label>);
expect(getByText('monospace text')).toBeInTheDocument();
});
// test stories from the storybook!
test('renders all the storybook gallery variants', () => {
// @ts-expect-error: Suppress TypeScript error for LabelGallery usage

View File

@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page } from '@playwright/test';
import { Modal } from '../core';
/**
* Chart properties edit modal.
* Opened by clicking the edit icon on a chart row in the chart list.
* General section is expanded by default (defaultActiveKey="general").
*/
export class ChartPropertiesModal extends Modal {
private static readonly SELECTORS = {
NAME_INPUT: '[data-test="properties-modal-name-input"]',
};
constructor(page: Page) {
super(page, '[data-test="properties-edit-modal"]');
}
/**
* Fills the chart name input field
* @param name - The new chart name
*/
async fillName(name: string): Promise<void> {
const input = this.body.locator(ChartPropertiesModal.SELECTORS.NAME_INPUT);
await input.fill(name);
}
/**
* Clicks the Save button in the modal footer
*/
async clickSave(): Promise<void> {
await this.clickFooterButton('Save');
}
}

View File

@@ -19,6 +19,7 @@
import type { Response, APIResponse } from '@playwright/test';
import { expect } from '@playwright/test';
import * as unzipper from 'unzipper';
/**
* Common interface for response types with status() method.
@@ -59,3 +60,60 @@ export function expectStatusOneOf<T extends ResponseLike>(
).toContain(response.status());
return response;
}
interface ExportZipOptions {
/** Directory name containing resource yaml files (e.g. 'charts', 'datasets') */
resourceDir: string;
/** Minimum number of resource yaml files expected (default: 1) */
minCount?: number;
/** Regex to validate Content-Disposition header (skipped if omitted) */
contentDispositionPattern?: RegExp;
/** Resource names that must each appear in at least one YAML filepath */
expectedNames?: string[];
}
/**
* Validate an export zip response: content-type, zip structure, and resource yaml files.
* Shared across chart and dataset export tests.
*/
export async function expectValidExportZip(
response: ResponseLike,
options: ExportZipOptions,
): Promise<void> {
const {
resourceDir,
minCount = 1,
contentDispositionPattern,
expectedNames,
} = options;
expect(response.headers()['content-type']).toContain('application/zip');
if (contentDispositionPattern) {
expect(response.headers()['content-disposition']).toMatch(
contentDispositionPattern,
);
}
const body = await response.body();
expect(body.length).toBeGreaterThan(0);
const entries: string[] = [];
const directory = await unzipper.Open.buffer(body);
directory.files.forEach(file => entries.push(file.path));
const resourceYamlFiles = entries.filter(
entry => entry.includes(`${resourceDir}/`) && entry.endsWith('.yaml'),
);
expect(resourceYamlFiles.length).toBeGreaterThanOrEqual(minCount);
expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true);
if (expectedNames) {
for (const name of expectedNames) {
expect(
resourceYamlFiles.some(f => f.includes(name)),
`Expected export zip to contain a YAML file matching "${name}"`,
).toBe(true);
}
}
}

View File

@@ -0,0 +1,104 @@
/**
* 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 { Page, APIResponse } from '@playwright/test';
import {
apiGet,
apiPost,
apiDelete,
apiPut,
ApiRequestOptions,
} from './requests';
export const ENDPOINTS = {
CHART: 'api/v1/chart/',
CHART_EXPORT: 'api/v1/chart/export/',
} as const;
/**
* TypeScript interface for chart creation API payload.
* Only slice_name, datasource_id, datasource_type are required (ChartPostSchema).
*/
export interface ChartCreatePayload {
slice_name: string;
datasource_id: number;
datasource_type: string;
viz_type?: string;
params?: string;
}
/**
* POST request to create a chart
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Chart configuration object
* @returns API response from chart creation
*/
export async function apiPostChart(
page: Page,
requestBody: ChartCreatePayload,
): Promise<APIResponse> {
return apiPost(page, ENDPOINTS.CHART, requestBody);
}
/**
* GET request to fetch a chart's details
* @param page - Playwright page instance (provides authentication context)
* @param chartId - ID of the chart to fetch
* @param options - Optional request options
* @returns API response with chart details
*/
export async function apiGetChart(
page: Page,
chartId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.CHART}${chartId}`, options);
}
/**
* DELETE request to remove a chart
* @param page - Playwright page instance (provides authentication context)
* @param chartId - ID of the chart to delete
* @param options - Optional request options
* @returns API response from chart deletion
*/
export async function apiDeleteChart(
page: Page,
chartId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.CHART}${chartId}`, options);
}
/**
* PUT request to update a chart
* @param page - Playwright page instance (provides authentication context)
* @param chartId - ID of the chart to update
* @param data - Partial chart payload (Marshmallow allows optional fields)
* @param options - Optional request options
* @returns API response from chart update
*/
export async function apiPutChart(
page: Page,
chartId: number,
data: Record<string, unknown>,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiPut(page, `${ENDPOINTS.CHART}${chartId}`, data, options);
}

View File

@@ -18,6 +18,7 @@
*/
import { test as base } from '@playwright/test';
import { apiDeleteChart } from '../api/chart';
import { apiDeleteDataset } from '../api/dataset';
import { apiDeleteDatabase } from '../api/database';
@@ -26,40 +27,78 @@ import { apiDeleteDatabase } from '../api/database';
* Inspired by Cypress's cleanDashboards/cleanCharts pattern.
*/
export interface TestAssets {
trackChart(id: number): void;
trackDataset(id: number): void;
trackDatabase(id: number): void;
}
const EXPECTED_CLEANUP_STATUSES = new Set([200, 202, 204, 404]);
export const test = base.extend<{ testAssets: TestAssets }>({
testAssets: async ({ page }, use) => {
// Use Set to de-dupe IDs (same resource may be tracked multiple times)
const chartIds = new Set<number>();
const datasetIds = new Set<number>();
const databaseIds = new Set<number>();
await use({
trackChart: id => chartIds.add(id),
trackDataset: id => datasetIds.add(id),
trackDatabase: id => databaseIds.add(id),
});
// Cleanup: Delete datasets FIRST (they reference databases)
// Then delete databases. Use failOnStatusCode: false for tolerance.
// Cleanup order: charts → datasets → databases (respects FK dependencies)
// Use failOnStatusCode: false to avoid throwing on 404 (resource already deleted by test)
// Warn on unexpected status codes (401/403/500) that may indicate leaked state
await Promise.all(
[...chartIds].map(id =>
apiDeleteChart(page, id, { failOnStatusCode: false })
.then(response => {
if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
console.warn(
`[testAssets] Unexpected status ${response.status()} cleaning up chart ${id}`,
);
}
})
.catch(error => {
console.warn(`[testAssets] Failed to cleanup chart ${id}:`, error);
}),
),
);
await Promise.all(
[...datasetIds].map(id =>
apiDeleteDataset(page, id, { failOnStatusCode: false }).catch(error => {
console.warn(`[testAssets] Failed to cleanup dataset ${id}:`, error);
}),
apiDeleteDataset(page, id, { failOnStatusCode: false })
.then(response => {
if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
console.warn(
`[testAssets] Unexpected status ${response.status()} cleaning up dataset ${id}`,
);
}
})
.catch(error => {
console.warn(
`[testAssets] Failed to cleanup dataset ${id}:`,
error,
);
}),
),
);
await Promise.all(
[...databaseIds].map(id =>
apiDeleteDatabase(page, id, { failOnStatusCode: false }).catch(
error => {
apiDeleteDatabase(page, id, { failOnStatusCode: false })
.then(response => {
if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
console.warn(
`[testAssets] Unexpected status ${response.status()} cleaning up database ${id}`,
);
}
})
.catch(error => {
console.warn(
`[testAssets] Failed to cleanup database ${id}:`,
error,
);
},
),
}),
),
);
},

View File

@@ -0,0 +1,132 @@
/**
* 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 { Page, Locator } from '@playwright/test';
import { Table } from '../components/core';
import { BulkSelect } from '../components/ListView';
import { URL } from '../utils/urls';
/**
* Chart List Page object.
*/
export class ChartListPage {
private readonly page: Page;
private readonly table: Table;
readonly bulkSelect: BulkSelect;
/**
* Action button names for getByRole('button', { name })
* Verified: ChartList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
*/
private static readonly ACTION_BUTTONS = {
DELETE: 'delete',
EDIT: 'edit',
EXPORT: 'upload',
} as const;
constructor(page: Page) {
this.page = page;
this.table = new Table(page);
this.bulkSelect = new BulkSelect(page, this.table);
}
/**
* Navigate to the chart list page.
* Forces table view via URL parameter to avoid card view default
* (ListviewsDefaultCardView feature flag may enable card view).
*/
async goto(): Promise<void> {
await this.page.goto(`${URL.CHART_LIST}?viewMode=table`);
}
/**
* Wait for the table to load
* @param options - Optional wait options
*/
async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
await this.table.waitForVisible(options);
}
/**
* Gets a chart row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @param chartName - The name of the chart
* @returns Locator for the chart row
*/
getChartRow(chartName: string): Locator {
return this.table.getRow(chartName);
}
/**
* Clicks the delete action button for a chart
* @param chartName - The name of the chart to delete
*/
async clickDeleteAction(chartName: string): Promise<void> {
const row = this.table.getRow(chartName);
await row
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.DELETE })
.click();
}
/**
* Clicks the edit action button for a chart
* @param chartName - The name of the chart to edit
*/
async clickEditAction(chartName: string): Promise<void> {
const row = this.table.getRow(chartName);
await row
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EDIT })
.click();
}
/**
* Clicks the export action button for a chart
* @param chartName - The name of the chart to export
*/
async clickExportAction(chartName: string): Promise<void> {
const row = this.table.getRow(chartName);
await row
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EXPORT })
.click();
}
/**
* Clicks the "Bulk select" button to enable bulk selection mode
*/
async clickBulkSelectButton(): Promise<void> {
await this.bulkSelect.enable();
}
/**
* Selects a chart's checkbox in bulk select mode
* @param chartName - The name of the chart to select
*/
async selectChartCheckbox(chartName: string): Promise<void> {
await this.bulkSelect.selectRow(chartName);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
}

View File

@@ -0,0 +1,307 @@
/**
* 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 {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { ChartListPage } from '../../../pages/ChartListPage';
import { ChartPropertiesModal } from '../../../components/modals/ChartPropertiesModal';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { Toast } from '../../../components/core/Toast';
import { apiGetChart, ENDPOINTS } from '../../../helpers/api/chart';
import { createTestChart } from './chart-test-helpers';
import { waitForGet, waitForPut } from '../../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
*/
const test = testWithAssets.extend<{ chartListPage: ChartListPage }>({
chartListPage: async ({ page }, use) => {
const chartListPage = new ChartListPage(page);
await chartListPage.goto();
await chartListPage.waitForTableLoad();
await use(chartListPage);
},
});
test('should delete a chart with confirmation', async ({
page,
chartListPage,
testAssets,
}) => {
// Create throwaway chart for deletion
const { id: chartId, name: chartName } = await createTestChart(
page,
testAssets,
test.info(),
{ prefix: 'test_delete' },
);
// Refresh to see the new chart (created via API)
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// Click delete action button
await chartListPage.clickDeleteAction(chartName);
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify chart is removed from list
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
// Backend verification: API returns 404
await expect
.poll(
async () => {
const response = await apiGetChart(page, chartId, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Chart ${chartId} should return 404` },
)
.toBe(404);
});
test('should edit chart name via properties modal', async ({
page,
chartListPage,
testAssets,
}) => {
// Create throwaway chart for editing
const { id: chartId, name: chartName } = await createTestChart(
page,
testAssets,
test.info(),
{ prefix: 'test_edit' },
);
// Refresh to see the new chart
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// Click edit action to open properties modal
await chartListPage.clickEditAction(chartName);
// Wait for properties modal to be ready
const propertiesModal = new ChartPropertiesModal(page);
await propertiesModal.waitForReady();
// Edit the chart name
const newName = `renamed_${Date.now()}_${test.info().parallelIndex}`;
await propertiesModal.fillName(newName);
// Set up response intercept for save
const saveResponsePromise = waitForPut(page, `${ENDPOINTS.CHART}${chartId}`);
// Click Save button
await propertiesModal.clickSave();
// Wait for save to complete and verify success
expectStatusOneOf(await saveResponsePromise, [200, 201]);
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
const chart = (await response.json()).result;
expect(chart.slice_name).toBe(newName);
});
test('should export a chart as a zip file', async ({
page,
chartListPage,
testAssets,
}) => {
// Create throwaway chart for export
const { name: chartName } = await createTestChart(
page,
testAssets,
test.info(),
{ prefix: 'test_export' },
);
// Refresh to see the new chart
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
// Click export action button
await chartListPage.clickExportAction(chartName);
// Wait for export API response and validate zip contents
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, {
resourceDir: 'charts',
expectedNames: [chartName],
});
});
test('should bulk delete multiple charts', async ({
page,
chartListPage,
testAssets,
}) => {
test.setTimeout(60_000);
// Create 2 throwaway charts for bulk delete
const [chart1, chart2] = await Promise.all([
createTestChart(page, testAssets, test.info(), {
prefix: 'bulk_delete_1',
}),
createTestChart(page, testAssets, test.info(), {
prefix: 'bulk_delete_2',
}),
]);
// Refresh to see new charts
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
// Select both charts
await chartListPage.selectChartCheckbox(chart1.name);
await chartListPage.selectChartCheckbox(chart2.name);
// Click bulk delete action
await chartListPage.clickBulkAction('Delete');
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both charts are removed from list
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
// Backend verification: Both return 404
for (const chart of [chart1, chart2]) {
await expect
.poll(
async () => {
const response = await apiGetChart(page, chart.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Chart ${chart.id} should return 404` },
)
.toBe(404);
}
});
test('should bulk export multiple charts', async ({
page,
chartListPage,
testAssets,
}) => {
// Create 2 throwaway charts for bulk export
const [chart1, chart2] = await Promise.all([
createTestChart(page, testAssets, test.info(), {
prefix: 'bulk_export_1',
}),
createTestChart(page, testAssets, test.info(), {
prefix: 'bulk_export_2',
}),
]);
// Refresh to see new charts
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
// Select both charts
await chartListPage.selectChartCheckbox(chart1.name);
await chartListPage.selectChartCheckbox(chart2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
// Click bulk export action
await chartListPage.clickBulkAction('Export');
// Wait for export API response and validate zip contains both charts
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, {
resourceDir: 'charts',
minCount: 2,
expectedNames: [chart1.name, chart2.name],
});
});

View File

@@ -0,0 +1,88 @@
/**
* 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 type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
interface TestChartResult {
id: number;
name: string;
}
interface CreateTestChartOptions {
/** Prefix for generated name (default: 'test_chart') */
prefix?: string;
}
/**
* Creates a test chart via the API for E2E testing.
* Uses the members_channels_2 dataset (loaded via --load-examples).
*
* @example
* const { id, name } = await createTestChart(page, testAssets, test.info());
*
* @example
* const { id, name } = await createTestChart(page, testAssets, test.info(), {
* prefix: 'test_delete',
* });
*/
export async function createTestChart(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options?: CreateTestChartOptions,
): Promise<TestChartResult> {
const prefix = options?.prefix ?? 'test_chart';
const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`;
// Look up the members_channels_2 dataset for chart creation
const dataset = await getDatasetByName(page, 'members_channels_2');
if (!dataset) {
throw new Error(
'members_channels_2 dataset not found — run Superset with --load-examples',
);
}
const response = await apiPostChart(page, {
slice_name: name,
datasource_id: dataset.id,
datasource_type: 'table',
viz_type: 'table',
params: '{}',
});
if (!response.ok()) {
throw new Error(`Failed to create test chart: ${response.status()}`);
}
const body = await response.json();
// Handle both response shapes: { id } or { result: { id } }
const id = body.result?.id ?? body.id;
if (!id) {
throw new Error(
`Chart creation returned no id. Response: ${JSON.stringify(body)}`,
);
}
testAssets.trackChart(id);
return { id, name };
}

View File

@@ -21,9 +21,7 @@ import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import type { Response } from '@playwright/test';
import path from 'path';
import * as unzipper from 'unzipper';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
@@ -45,7 +43,10 @@ import {
waitForPost,
waitForPut,
} from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
/**
@@ -60,40 +61,6 @@ const test = testWithAssets.extend<{ datasetListPage: DatasetListPage }>({
},
});
/**
* Helper to validate an export zip response.
* Verifies headers, parses zip contents, and validates expected structure.
*/
async function expectValidExportZip(
response: Response,
options: { minDatasetCount?: number; checkContentDisposition?: boolean } = {},
): Promise<void> {
const { minDatasetCount = 1, checkContentDisposition = false } = options;
// Verify headers
expect(response.headers()['content-type']).toContain('application/zip');
if (checkContentDisposition) {
expect(response.headers()['content-disposition']).toMatch(
/filename=.*dataset_export.*\.zip/,
);
}
// Parse and validate zip contents
const body = await response.body();
expect(body.length).toBeGreaterThan(0);
const entries: string[] = [];
const directory = await unzipper.Open.buffer(body);
directory.files.forEach(file => entries.push(file.path));
// Validate structure
const datasetYamlFiles = entries.filter(
entry => entry.includes('datasets/') && entry.endsWith('.yaml'),
);
expect(datasetYamlFiles.length).toBeGreaterThanOrEqual(minDatasetCount);
expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true);
}
test('should navigate to Explore when dataset name is clicked', async ({
page,
datasetListPage,
@@ -286,7 +253,10 @@ test('should export a dataset as a zip file', async ({
// Wait for export API response and validate zip contents
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, { checkContentDisposition: true });
await expectValidExportZip(exportResponse, {
resourceDir: 'datasets',
contentDispositionPattern: /filename=.*dataset_export.*\.zip/,
});
});
test('should export multiple datasets via bulk select action', async ({
@@ -327,7 +297,10 @@ test('should export multiple datasets via bulk select action', async ({
// Wait for export API response and validate zip contains multiple datasets
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, { minDatasetCount: 2 });
await expectValidExportZip(exportResponse, {
resourceDir: 'datasets',
minCount: 2,
});
});
test('should edit dataset name via modal', async ({

View File

@@ -233,7 +233,7 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
) {
ctx.beginPath();
if (location.properties.cluster) {
let clusterLabel = clusterLabelMap[i];
const clusterLabel = clusterLabelMap[i];
// Validate clusterLabel is a finite number before using it for radius calculation
const numericLabel = Number(clusterLabel);
const safeNumericLabel = Number.isFinite(numericLabel)

View File

@@ -29,7 +29,7 @@
"access": "public"
},
"dependencies": {
"@types/geojson": "^7946.0.10",
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
"lodash": "^4.17.23"
},

View File

@@ -142,8 +142,8 @@ const naturalSort: SortFunction = (as, bs) => {
}
// finally, "smart" string sorting per http://stackoverflow.com/a/4373421/112871
let a = String(as);
let b = String(bs);
const a = String(as);
const b = String(bs);
if (a === b) {
return 0;
}

View File

@@ -614,9 +614,7 @@ describe('plugin-chart-table', () => {
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'',
);
expect(getComputedStyle(screen.getByText('N/A')).background).toBe(
'rgba(172, 225, 196, 1)',
);
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
});
test('should display original label in grouped headers', () => {
const props = transformProps(testData.comparison);

View File

@@ -531,6 +531,7 @@ const ResultSet = ({
placement="left"
>
<Label
monospace
css={css`
line-height: ${theme.fontSizeLG}px;
`}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 355.42 184.17">
<defs>
<style>
.cls-1 {
fill: #dc150b;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #000;
}
.cls-3 {
fill: #fff;
}
</style>
</defs>
<g>
<polygon class="cls-2" points="151.67 73.23 168.02 73.23 168.02 121.29 179.52 121.29 179.52 73.23 195.87 73.23 195.87 62.89 151.67 62.89 151.67 73.23"/>
<path class="cls-2" d="M207.29,61.64c-1.75,0-3.28.62-4.56,1.85-1.28,1.23-1.94,2.77-1.94,4.57,0,1.7.65,3.19,1.93,4.45,1.28,1.25,2.81,1.89,4.56,1.89s3.19-.64,4.45-1.89,1.89-2.75,1.89-4.45c0-1.8-.62-3.33-1.85-4.56s-2.74-1.85-4.48-1.85Z"/>
<rect class="cls-2" x="201.71" y="79.4" width="11.17" height="41.89"/>
<path class="cls-2" d="M243.46,62.89h-20.53v58.4h20.53c8.85,0,15.88-2.64,20.9-7.85,5.01-5.21,7.56-12.39,7.56-21.35s-2.54-16.21-7.56-21.39c-5.02-5.18-12.05-7.81-20.9-7.81ZM234.43,73.23h8.62c5.72,0,10.04,1.66,12.86,4.93,2.83,3.29,4.26,7.86,4.26,13.59s-1.44,10.49-4.27,13.97c-2.82,3.46-7.14,5.22-12.86,5.22h-8.62v-37.72Z"/>
<path class="cls-2" d="M318.75,95.24c-1.9-2.31-4.16-3.97-6.7-4.93v-.1c5.32-2.58,8.01-6.81,8.01-12.57,0-4.47-1.48-8.09-4.39-10.75-2.91-2.65-7.2-4-12.75-4h-24.08v58.4h23.92c5.88,0,10.56-1.44,13.9-4.28,3.36-2.85,5.06-6.9,5.06-12.04,0-4.07-1-7.34-2.96-9.73ZM290.08,86.58v-14.35h11.34c2.16,0,3.91.64,5.2,1.9,1.28,1.26,1.93,3.02,1.93,5.24s-.64,4-1.9,5.28c-1.26,1.28-3.07,1.93-5.4,1.93h-11.18ZM309.96,104.14c0,2.33-.71,4.23-2.1,5.65-1.39,1.42-3.45,2.14-6.1,2.14h-11.67v-16h11.67c2.54,0,4.57.77,6.02,2.3,1.45,1.53,2.19,3.52,2.19,5.9Z"/>
</g>
<g>
<polygon class="cls-1" points="84.27 33.71 33.71 62.9 33.71 121.27 84.27 150.46 134.82 121.27 134.82 62.9 84.27 33.71"/>
<polygon class="cls-3" points="67.41 121.27 67.41 82.36 50.56 92.09 50.56 72.63 84.27 53.17 101.12 62.9 84.27 72.63 84.27 131 67.41 121.27"/>
<polygon class="cls-3" points="101.12 121.27 101.12 82.36 117.97 72.63 117.97 111.54 101.12 121.27"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,16 @@
<svg width="2502" height="500" viewBox="0 0 2502 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1731.18 88.8069C1803.56 88.8069 1849.96 145.403 1834.66 233.595C1818.86 321.787 1752.09 380.412 1680.21 380.412C1635.85 380.412 1607.81 358.487 1594.55 335.547L1574.21 451.922C1571.85 465.385 1564.82 477.585 1554.36 486.379C1543.9 495.172 1530.67 499.995 1517 500H1499.12C1498.48 500.001 1497.86 499.862 1497.28 499.594C1496.7 499.326 1496.19 498.935 1495.78 498.448C1495.38 497.962 1495.08 497.391 1494.91 496.778C1494.75 496.164 1494.72 495.521 1494.83 494.895L1564.88 97.0018C1565.05 95.9919 1565.58 95.0764 1566.37 94.4178C1567.15 93.7592 1568.14 93.4 1569.17 93.4041H1632.21C1632.84 93.403 1633.47 93.5415 1634.04 93.8097C1634.62 94.0779 1635.13 94.4693 1635.54 94.9563C1635.95 95.4432 1636.24 96.0138 1636.4 96.6276C1636.57 97.2414 1636.59 97.8836 1636.48 98.5086L1630.24 134.179C1642.94 119.955 1658.49 108.564 1675.88 100.747C1693.27 92.9293 1712.11 88.8612 1731.18 88.8069V88.8069ZM1701.61 151.522C1663.37 151.522 1621.66 181.089 1612.43 234.548C1602.75 288.084 1633.33 317.574 1672.07 317.574C1710.82 317.574 1752.12 286.977 1761.8 233.456C1770.95 180.058 1740.4 151.522 1701.62 151.522H1701.61Z" fill="#2F2D32"/>
<path d="M1972.4 0H1909.25C1908.26 0.00280712 1907.3 0.353853 1906.54 0.991803C1905.78 1.62975 1905.27 2.51404 1905.1 3.49016L1840.34 370.203C1840.23 370.84 1840.25 371.494 1840.42 372.119C1840.59 372.744 1840.89 373.325 1841.31 373.82C1841.72 374.316 1842.24 374.714 1842.83 374.987C1843.42 375.26 1844.06 375.401 1844.7 375.4H1907.85C1908.84 375.401 1909.8 375.053 1910.56 374.417C1911.32 373.782 1911.83 372.9 1912 371.925L1976.76 5.21218C1976.87 4.57417 1976.85 3.91938 1976.68 3.29367C1976.51 2.66796 1976.21 2.08646 1975.79 1.58992C1975.38 1.09337 1974.86 0.693775 1974.27 0.419132C1973.68 0.144489 1973.04 0.00143844 1972.4 0V0Z" fill="#2F2D32"/>
<path d="M2258.34 92.6353H2195.87C2194.83 92.6369 2193.82 93.0042 2193.02 93.673C2192.23 94.3418 2191.69 95.2696 2191.5 96.2945L2164.6 248.309C2156.91 293.742 2127.34 318.235 2089.06 318.235C2050.78 318.235 2030.36 293.742 2038.01 248.309L2064.66 97.8628C2064.77 97.2228 2064.74 96.566 2064.57 95.9384C2064.41 95.3109 2064.1 94.7277 2063.69 94.2297C2063.27 93.7318 2062.75 93.331 2062.16 93.0556C2061.57 92.7802 2060.93 92.6367 2060.28 92.6353H1997.82C1996.79 92.6344 1995.79 92.9996 1994.99 93.6662C1994.2 94.3329 1993.67 95.2583 1993.49 96.2792L1965.03 258.518C1954.64 317.051 1974.76 355.827 2013.75 371.325L2014.05 371.464L2014.88 371.756C2018.2 373.024 2021.58 374.112 2025.02 375.015C2050.41 382.242 2091.14 386.424 2145.75 354.382L2143.03 370.234C2142.92 370.868 2142.95 371.518 2143.12 372.139C2143.28 372.761 2143.58 373.337 2144 373.83C2144.41 374.322 2144.93 374.717 2145.51 374.989C2146.1 375.26 2146.73 375.4 2147.38 375.4H2209.88C2210.92 375.399 2211.92 375.034 2212.72 374.368C2213.52 373.702 2214.06 372.778 2214.24 371.756L2262.69 97.8321C2262.8 97.1961 2262.77 96.5433 2262.61 95.9194C2262.44 95.2956 2262.14 94.7158 2261.72 94.2206C2261.31 93.7255 2260.79 93.327 2260.21 93.0532C2259.62 92.7793 2258.99 92.6367 2258.34 92.6353V92.6353Z" fill="#2F2D32"/>
<path d="M2393.84 88.0381C2325.44 88.0381 2278.99 123.263 2278.99 173.293C2278.99 265.16 2419.35 250.861 2419.35 296.802C2419.35 316.79 2397.41 325.892 2371.88 325.892C2343.64 325.892 2326.62 311.516 2326.3 290.36C2326.27 289.212 2325.8 288.121 2324.98 287.321C2324.15 286.521 2323.05 286.077 2321.9 286.085H2260.98C2259.89 286.078 2258.84 286.472 2258.02 287.192C2257.21 287.913 2256.68 288.909 2256.56 289.991C2251.27 343.035 2300.51 379.997 2366.78 379.997C2436.2 379.997 2488.24 348.355 2488.24 294.757C2488.24 206.965 2348.9 218.712 2348.9 171.248C2348.9 153.382 2366.26 142.143 2390.75 142.143C2418.99 142.143 2435.11 155.981 2435.34 177.137C2435.36 178.293 2435.83 179.394 2436.66 180.202C2437.48 181.011 2438.59 181.462 2439.75 181.458H2497.41C2498.53 181.464 2499.62 181.042 2500.44 180.276C2501.27 179.511 2501.77 178.46 2501.85 177.337C2504.91 124.908 2461.03 88.0381 2393.84 88.0381Z" fill="#2F2D32"/>
<path d="M539.071 126.891H500.264V96.7558H539.071V0H575.325V96.7558H651.847V126.891H575.325V299.969C575.325 334.179 587.056 344.895 619.221 344.895H651.847V375.538H613.609C565.101 375.538 539.071 355.627 539.071 299.969V126.891Z" fill="#2F2D32"/>
<path d="M688.609 96.7559H724.356V375.538H688.609V96.7559Z" fill="#2F2D32"/>
<path d="M1170.39 216.236C1170.39 154.459 1138.22 122.801 1088.7 122.801C1038.15 122.801 1002.4 156.488 1002.4 224.477V375.615H967.174V216.236C967.174 154.459 934.502 122.801 884.979 122.801C833.918 122.801 798.694 156.488 798.694 224.4V375.538H762.947V96.7559H798.694V144.757C816.56 109.517 851.784 91.6514 891.098 91.6514C937.561 91.6514 977.383 113.607 994.234 160.071C1009.61 114.622 1050.4 91.6514 1094.82 91.6514C1156.59 91.6514 1205.61 130.458 1205.61 212.146V375.538H1170.39V216.236Z" fill="#2F2D32"/>
<path d="M1370.88 379.612C1291.74 379.612 1234.04 324.477 1234.04 236.147C1234.04 147.309 1290.71 92.6814 1370.88 92.6814C1452.58 92.6814 1502.61 150.876 1502.61 221.833C1502.81 231.208 1502.47 240.587 1501.6 249.923H1270.31C1273.38 315.267 1318.8 349.477 1370.88 349.477C1418.37 349.477 1450.53 324.462 1460.74 287.192H1498.52C1485.76 339.791 1440.83 379.612 1370.88 379.612ZM1270.31 220.833H1466.36C1467.89 155.981 1420.92 123.309 1369.35 123.309C1318.8 123.309 1274.38 155.981 1270.31 220.833Z" fill="#2F2D32"/>
<path d="M684.288 37.0849L690.607 0H725.97L719.651 37.0849H684.288Z" fill="#2F2D32"/>
<path d="M290.749 234.21C290.748 213.232 286.199 192.505 277.416 173.455C268.633 154.405 255.824 137.486 239.873 123.862C238.212 123.339 236.536 122.827 234.845 122.325C224.342 125.132 214.289 129.413 204.987 135.04C222.098 145.313 236.259 159.841 246.09 177.21C255.921 194.579 261.088 214.197 261.088 234.156C261.088 254.114 255.921 273.733 246.09 291.102C236.259 308.471 222.098 322.999 204.987 333.272C214.287 338.9 224.34 343.176 234.845 345.972C236.536 345.526 238.212 345.034 239.873 344.511C255.818 330.892 268.622 313.98 277.405 294.939C286.188 275.897 290.74 255.179 290.749 234.21Z" fill="#F2DBF3"/>
<path d="M29.6033 234.21C29.6373 209.607 37.4958 185.652 52.0422 165.81C66.5887 145.967 87.0692 131.266 110.522 123.831C129.056 107.91 151.31 96.9259 175.22 91.8973C154.055 87.4625 132.167 87.8046 111.151 92.8985C90.1351 97.9924 70.5197 107.71 53.7339 121.343C36.9481 134.976 23.4145 152.182 14.119 171.707C4.82343 191.232 0 212.585 0 234.21C0 255.835 4.82343 277.187 14.119 296.712C23.4145 316.237 36.9481 333.443 53.7339 347.076C70.5197 360.71 90.1351 370.427 111.151 375.521C132.167 380.615 154.055 380.957 175.22 376.522C151.351 371.48 129.14 360.496 110.645 344.588C87.168 337.177 66.6592 322.486 52.0889 302.641C37.5186 282.796 29.6432 258.829 29.6033 234.21V234.21Z" fill="#F2DBF3"/>
<path d="M350.496 234.21C350.495 213.223 345.941 192.487 337.15 173.431C328.359 154.375 315.539 137.453 299.574 123.831C278.596 117.228 256.173 116.726 234.921 122.386C259.562 128.955 281.343 143.478 296.88 163.7C312.417 183.921 320.84 208.708 320.84 234.21C320.84 259.711 312.417 284.498 296.88 304.719C281.343 324.941 259.562 339.464 234.921 346.033C256.171 351.714 278.6 351.213 299.574 344.588C315.541 330.968 328.363 314.046 337.154 294.99C345.946 275.933 350.498 255.196 350.496 234.21V234.21Z" fill="#EA9FCC"/>
<path d="M170.192 344.588C146.723 337.181 126.226 322.487 111.676 302.638C97.1264 282.79 89.282 258.82 89.282 234.21C89.282 209.599 97.1264 185.63 111.676 165.781C126.226 145.932 146.723 131.238 170.192 123.831C188.713 107.917 210.95 96.9332 234.844 91.8973C213.679 87.4625 191.792 87.8046 170.775 92.8985C149.759 97.9924 130.144 107.71 113.358 121.343C96.5722 134.976 83.0386 152.182 73.7431 171.707C64.4475 191.232 59.6241 212.585 59.6241 234.21C59.6241 255.835 64.4475 277.187 73.7431 296.712C83.0386 316.237 96.5722 333.443 113.358 347.076C130.144 360.71 149.759 370.427 170.775 375.521C191.792 380.615 213.679 380.957 234.844 376.522C210.948 371.493 188.71 360.508 170.192 344.588V344.588Z" fill="#EA9FCC"/>
<path d="M264.688 88.8069C235.93 88.8069 207.818 97.3346 183.907 113.312C159.996 129.289 141.359 151.998 130.354 178.566C119.349 205.135 116.469 234.371 122.08 262.576C127.69 290.782 141.538 316.69 161.873 337.025C182.208 357.36 208.116 371.208 236.321 376.819C264.526 382.429 293.762 379.55 320.331 368.544C346.899 357.539 369.608 338.903 385.585 314.991C401.562 291.08 410.09 262.968 410.09 234.21C410.045 195.66 394.711 158.702 367.453 131.444C340.194 104.185 303.237 88.8516 264.688 88.8069V88.8069ZM380.416 234.21C380.419 257.103 373.633 279.483 360.916 298.52C348.199 317.556 330.122 332.394 308.972 341.156C287.823 349.918 264.549 352.212 242.096 347.746C219.642 343.28 199.017 332.257 182.829 316.069C166.641 299.881 155.618 279.255 151.152 256.802C146.687 234.348 148.98 211.075 157.742 189.925C166.505 168.775 181.342 150.698 200.378 137.981C219.415 125.264 241.794 118.478 264.688 118.481C295.369 118.518 324.784 130.722 346.479 152.418C368.175 174.113 380.379 203.528 380.416 234.21V234.21Z" fill="#FF244E"/>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -39,7 +39,7 @@ export default function RowCountLabel(props: RowCountLabelProps) {
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
const formattedRowCount = getNumberFormatter()(rowcount);
const labelText = (
<Label type={type}>
<Label type={type} monospace>
{loading ? (
t('Loading...')
) : (

View File

@@ -221,7 +221,7 @@ test('should render a DeleteComponentButton in editMode', () => {
/* oxlint-disable-next-line jest/no-disabled-tests */
test.skip('should render a BackgroundStyleDropdown when focused', () => {
let { rerender } = setup({ component: rowWithoutChildren });
const { rerender } = setup({ component: rowWithoutChildren });
expect(screen.queryByTestId('background-style-dropdown')).toBeFalsy();
// we cannot set props on the Row because of the WithDragDropContext wrapper

View File

@@ -24,6 +24,7 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import rison from 'rison';
import RoleListEditModal from './RoleListEditModal';
import {
updateRoleName,
@@ -208,4 +209,40 @@ describe('RoleListEditModal', () => {
expect(screen.getByTitle('User Name')).toBeInTheDocument();
expect(screen.getByTitle('Email')).toBeInTheDocument();
});
test('fetches users with correct role relationship filter', async () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockResolvedValue({
json: {
count: 0,
result: [],
},
});
render(<RoleListEditModal {...mockProps} />);
await waitFor(() => {
expect(mockGet).toHaveBeenCalled();
});
// verify the endpoint and query parameters
const callArgs = mockGet.mock.calls[0][0];
expect(callArgs.endpoint).toContain('/api/v1/security/users/');
const urlMatch = callArgs.endpoint.match(/\?q=(.+)/);
expect(urlMatch).toBeTruthy();
const decodedQuery = rison.decode(urlMatch[1]);
expect(decodedQuery).toEqual({
page_size: 100,
page: 0,
filters: [
{
col: 'roles',
opr: 'rel_m_m',
value: mockRole.id,
},
],
});
});
});

View File

@@ -104,7 +104,7 @@ function RoleListEditModal({
permissions,
groups,
}: RoleListEditModalProps) {
const { id, name, permission_ids, user_ids, group_ids } = role;
const { id, name, permission_ids, group_ids } = role;
const [activeTabKey, setActiveTabKey] = useState(roleTabs.edit.key);
const { addDangerToast, addSuccessToast } = useToasts();
const [roleUsers, setRoleUsers] = useState<UserObject[]>([]);
@@ -112,13 +112,7 @@ function RoleListEditModal({
const formRef = useRef<FormInstance | null>(null);
useEffect(() => {
if (!user_ids.length) {
setRoleUsers([]);
setLoadingRoleUsers(false);
return;
}
const filters = [{ col: 'id', opr: 'in', value: user_ids }];
const filters = [{ col: 'roles', opr: 'rel_m_m', value: id }];
fetchPaginatedData({
endpoint: `/api/v1/security/users/`,
@@ -137,7 +131,7 @@ function RoleListEditModal({
email: user.email,
}),
});
}, [user_ids]);
}, [id]);
useEffect(() => {
if (!loadingRoleUsers && formRef.current && roleUsers.length >= 0) {

View File

@@ -17,11 +17,12 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import ThemeModal from './ThemeModal';
import { ThemeObject } from './types';
import { validateTheme } from 'src/theme/utils/themeStructureValidation';
const mockThemeContext = {
setTemporaryTheme: jest.fn(),
@@ -37,6 +38,27 @@ jest.mock('src/dashboard/util/permissionUtils', () => ({
isUserAdmin: jest.fn(() => true),
}));
// Mock JsonEditor to avoid direct DOM manipulation in tests
jest.mock('@superset-ui/core/components/AsyncAceEditor', () => ({
...jest.requireActual('@superset-ui/core/components/AsyncAceEditor'),
JsonEditor: ({
onChange,
value,
readOnly,
}: {
onChange: (value: string) => void;
value: string;
readOnly?: boolean;
}) => (
<textarea
data-test="json-editor"
value={value}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
),
}));
const mockTheme: ThemeObject = {
id: 1,
theme_name: 'Test Theme',
@@ -89,6 +111,31 @@ afterEach(() => {
jest.clearAllMocks();
});
// Helper to add valid JSON data to the theme
// Uses the mocked JsonEditor textarea for testing
const addValidJsonData = async () => {
const validJson = JSON.stringify(
{ token: { colorPrimary: '#1890ff' } },
null,
2,
);
const jsonEditor = screen.getByTestId('json-editor');
await userEvent.clear(jsonEditor);
await userEvent.type(jsonEditor, validJson);
};
// Helper to add JSON with unknown tokens (triggers warnings but not errors)
const addJsonWithUnknownToken = async () => {
const jsonWithUnknown = JSON.stringify(
{ token: { colorPrimary: '#1890ff', unknownTokenName: 'value' } },
null,
2,
);
const jsonEditor = screen.getByTestId('json-editor');
await userEvent.clear(jsonEditor);
await userEvent.type(jsonEditor, jsonWithUnknown);
};
test('renders modal with add theme dialog when show is true', () => {
render(
<ThemeModal
@@ -283,10 +330,16 @@ test('enables save button when theme name is entered', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'My New Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
// Wait for validation to complete and button to become enabled
await waitFor(
() => {
const saveButton = screen.getByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
},
{ timeout: 10000 },
);
});
test('validates JSON format and enables save button', async () => {
@@ -304,10 +357,52 @@ test('validates JSON format and enables save button', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'Test Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
// Wait for validation to complete and button to become enabled
await waitFor(
() => {
const saveButton = screen.getByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
},
{ timeout: 10000 },
);
});
expect(saveButton).toBeEnabled();
test('warnings do not block save - unknown tokens allow save with warnings', async () => {
// First verify the test data actually produces warnings (not errors)
const testTheme = {
token: { colorPrimary: '#1890ff', unknownTokenName: 'value' },
};
const validationResult = validateTheme(testTheme);
expect(validationResult.valid).toBe(true); // No errors
expect(validationResult.warnings.length).toBeGreaterThan(0); // Has warnings
expect(validationResult.warnings[0].tokenName).toBe('unknownTokenName');
render(
<ThemeModal
addDangerToast={jest.fn()}
addSuccessToast={jest.fn()}
onThemeAdd={jest.fn()}
onHide={jest.fn()}
show
canDevelop={false}
/>,
{ useRedux: true, useRouter: true },
);
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'Theme With Unknown Token');
await addJsonWithUnknownToken();
// Wait for validation to complete - button should still be enabled despite warnings
await waitFor(
() => {
const saveButton = screen.getByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
},
{ timeout: 10000 },
);
});
test('shows unsaved changes alert when closing modal with modifications', async () => {
@@ -418,6 +513,19 @@ test('saves changes when clicking Save button in unsaved changes alert', async (
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'Modified Theme');
await addValidJsonData();
// Wait for validation to complete before canceling
await waitFor(
() => {
const addButton = screen.getByRole('button', { name: 'Add' });
expect(addButton).toBeEnabled();
},
{ timeout: 10000 },
);
// Give extra time for all state updates to complete
await new Promise(resolve => setTimeout(resolve, 500));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await userEvent.click(cancelButton);
@@ -426,13 +534,26 @@ test('saves changes when clicking Save button in unsaved changes alert', async (
await screen.findByText('You have unsaved changes'),
).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: 'Save' });
// Wait for the Save button in the alert to be enabled
const saveButton = await waitFor(
() => {
const button = screen.getByRole('button', { name: 'Save' });
expect(button).toBeEnabled();
return button;
},
{ timeout: 10000 },
);
await userEvent.click(saveButton);
// Wait for API call to complete
await screen.findByRole('dialog');
expect(fetchMock.callHistory.called()).toBe(true);
});
await waitFor(
() => {
expect(fetchMock.callHistory.called()).toBe(true);
},
{ timeout: 15000 },
);
}, 30000);
test('discards changes when clicking Discard button in unsaved changes alert', async () => {
const onHide = jest.fn();
@@ -483,12 +604,23 @@ test('creates new theme when saving', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'New Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
// Wait for validation to complete and button to become enabled
const saveButton = await waitFor(
() => {
const button = screen.getByRole('button', { name: 'Add' });
expect(button).toBeEnabled();
return button;
},
{ timeout: 10000 },
);
await userEvent.click(saveButton);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(fetchMock.callHistory.called(postThemeMockName)).toBe(true);
await waitFor(() => {
expect(fetchMock.callHistory.called(postThemeMockName)).toBe(true);
});
});
test('updates existing theme when saving', async () => {
@@ -536,14 +668,24 @@ test('handles API errors gracefully', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'New Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
// Wait for validation to complete and button to become enabled
const saveButton = await waitFor(
() => {
const button = screen.getByRole('button', { name: 'Add' });
expect(button).toBeEnabled();
return button;
},
{ timeout: 10000 },
);
await userEvent.click(saveButton);
await screen.findByRole('dialog');
expect(fetchMock.callHistory.called()).toBe(true);
await waitFor(() => {
expect(fetchMock.callHistory.called()).toBe(true);
});
});
test('applies theme locally when clicking Apply button', async () => {

View File

@@ -43,10 +43,10 @@ import {
Space,
Tooltip,
} from '@superset-ui/core/components';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import type { editors } from '@apache-superset/core';
import { EditorHost } from 'src/core/editors';
import { Typography } from '@superset-ui/core/components/Typography';
import { useThemeValidation } from 'src/theme/hooks/useThemeValidation';
import { OnlyKeyWithType } from 'src/utils/types';
import { ThemeObject } from './types';
@@ -147,10 +147,9 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
SupersetText?.THEME_MODAL?.DOCUMENTATION_URL ||
'https://superset.apache.org/docs/configuration/theming/';
// JSON validation annotations using reusable hook
const jsonAnnotations = useJsonValidation(currentTheme?.json_data, {
enabled: !isReadOnly,
errorPrefix: 'Invalid JSON syntax',
// Theme validation (structure + token names)
const validation = useThemeValidation(currentTheme?.json_data || '', {
enabled: !isReadOnly && Boolean(currentTheme?.json_data),
});
// theme fetch logic
@@ -178,6 +177,15 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
}, [onHide]);
const onSave = useCallback(() => {
// Synchronous JSON guard to catch invalid JSON before API call
// This handles the race condition where debounced validation hasn't updated yet
try {
JSON.parse(currentTheme?.json_data || '');
} catch {
addDangerToast(t('Invalid JSON configuration'));
return;
}
if (isEditMode) {
// Edit
if (currentTheme?.id) {
@@ -211,6 +219,7 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
createResource,
onThemeAdd,
hide,
addDangerToast,
]);
const handleCancel = useCallback(() => {
@@ -306,27 +315,22 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
[currentTheme],
);
const validate = useCallback(() => {
if (isReadOnly) {
const validate = () => {
if (isReadOnly || !currentTheme) {
setDisableSave(true);
return;
}
if (
currentTheme?.theme_name.length &&
currentTheme?.json_data?.length &&
isValidJson(currentTheme.json_data)
) {
setDisableSave(false);
} else {
setDisableSave(true);
}
}, [
currentTheme?.theme_name,
currentTheme?.json_data,
isReadOnly,
isValidJson,
]);
const hasValidName = Boolean(currentTheme?.theme_name?.trim());
const hasValidJsonData = Boolean(currentTheme?.json_data?.trim());
// Block save only on ERRORS (not warnings)
// Errors: JSON syntax errors, empty themes
// Warnings: Unknown tokens, null values (non-blocking)
const canSave = hasValidName && hasValidJsonData && !validation.hasErrors;
setDisableSave(!canSave);
};
// Initialize
useEffect(() => {
@@ -360,7 +364,12 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
// Validation
useEffect(() => {
validate();
}, [validate]);
}, [
currentTheme ? currentTheme.theme_name : '',
currentTheme ? currentTheme.json_data : '',
isReadOnly,
validation.hasErrors,
]);
// Show/hide
useEffect(() => {
@@ -490,7 +499,10 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
>
{t('documentation')}
</a>
{t(' for details.')}
{t(' for details.')}{' '}
<Typography.Text type="secondary">
{t('Unknown tokens will be highlighted as warnings.')}
</Typography.Text>
</span>
}
/>
@@ -506,7 +518,7 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
lineNumbers
width="100%"
height="250px"
annotations={toEditorAnnotations(jsonAnnotations)}
annotations={toEditorAnnotations(validation.annotations)}
/>
</StyledEditorWrapper>
{canDevelopThemes && (
@@ -520,7 +532,8 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
onClick={onApply}
disabled={
!currentTheme?.json_data ||
!isValidJson(currentTheme.json_data)
!isValidJson(currentTheme.json_data) ||
validation.hasErrors
}
buttonStyle="secondary"
>

View File

@@ -25,7 +25,7 @@ import {
} from '@superset-ui/core/utils/dates';
import { useEffect, useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Tooltip } from '@superset-ui/core/components';
import { Label, Tooltip } from '@superset-ui/core/components';
import { ListView } from 'src/components';
import SubMenu from 'src/features/home/SubMenu';
import withToasts from 'src/components/MessageToasts/withToasts';
@@ -149,8 +149,14 @@ function ExecutionLog({
},
}: {
row: { original: AnnotationObject };
}) =>
fDuration(new Date(startDttm).getTime(), new Date(endDttm).getTime()),
}) => (
<Label monospace>
{fDuration(
new Date(startDttm).getTime(),
new Date(endDttm).getTime(),
)}
</Label>
),
Header: t('Duration'),
disableSortBy: true,
id: 'duration',

View File

@@ -143,8 +143,26 @@ export class ThemeController {
// Setup change callback
if (onChange) this.onChangeCallbacks.add(onChange);
// Apply initial theme and persist mode
this.applyTheme(initialTheme);
// Apply initial theme with recovery for corrupted stored themes
try {
this.applyTheme(initialTheme);
} catch (error) {
// Corrupted dev override or CRUD theme in storage - clear and retry with defaults
console.warn(
'Failed to apply stored theme, clearing invalid overrides:',
error,
);
this.devThemeOverride = null;
this.crudThemeId = null;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
// Retry with clean default theme
this.currentMode = ThemeMode.DEFAULT;
const safeTheme = this.defaultTheme || {};
this.applyTheme(safeTheme);
}
this.persistMode();
}
@@ -229,14 +247,8 @@ export class ThemeController {
return this.dashboardThemes.get(themeId)!;
}
// Fetch theme config from API using SupersetClient for proper auth
const getTheme = makeApi<void, { result: { json_data: string } }>({
method: 'GET',
endpoint: `/api/v1/theme/${themeId}`,
});
const { result } = await getTheme();
const themeConfig = JSON.parse(result.json_data);
// Use the enhanced fetchCrudTheme method which includes validation if feature flag is enabled
const themeConfig = await this.fetchCrudTheme(themeId);
if (themeConfig) {
// Controller creates and owns the dashboard theme
@@ -329,7 +341,6 @@ export class ThemeController {
const theme: AnyThemeConfig | null = this.getThemeForMode(mode);
if (!theme) {
console.warn(`Theme for mode ${mode} not found, falling back to default`);
this.fallbackToDefaultMode();
return;
}
@@ -535,7 +546,7 @@ export class ThemeController {
* Updates the theme.
* @param theme - The new theme to apply
*/
private updateTheme(theme?: AnyThemeConfig): void {
private async updateTheme(theme?: AnyThemeConfig): Promise<void> {
try {
// If no config provided, use current mode to get theme
if (!theme) {
@@ -551,18 +562,41 @@ export class ThemeController {
this.persistMode();
this.notifyListeners();
} catch (error) {
console.error('Failed to update theme:', error);
this.fallbackToDefaultMode();
// Clear potentially corrupted overrides before fallback
// This mirrors the constructor's recovery logic to prevent
// repeated failures from a malformed devThemeOverride or crudThemeId
this.devThemeOverride = null;
this.crudThemeId = null;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
await this.fallbackToDefaultMode();
}
}
/**
* Fallback to default mode with error recovery.
* Fallback to default mode with runtime error recovery.
* Tries to fetch a fresh system default theme from the API.
*/
private fallbackToDefaultMode(): void {
private async fallbackToDefaultMode(): Promise<void> {
this.currentMode = ThemeMode.DEFAULT;
// Get the default theme which will have the correct algorithm
// Try to fetch fresh system default theme from server
const freshSystemTheme = await this.fetchSystemDefaultTheme();
if (freshSystemTheme) {
try {
await this.applyThemeWithRecovery(freshSystemTheme);
this.persistMode();
this.notifyListeners();
return;
} catch (error) {
// Fresh theme also failed, continue to final fallback
}
}
// Final fallback: use cached default theme or built-in theme
const defaultTheme: AnyThemeConfig =
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
@@ -796,10 +830,25 @@ export class ThemeController {
this.loadFonts(fontUrls);
} catch (error) {
console.error('Failed to apply theme:', error);
this.fallbackToDefaultMode();
// Re-throw the error so updateTheme can handle fallback logic
throw error;
}
}
private async applyThemeWithRecovery(theme: AnyThemeConfig): Promise<void> {
// Note: This method re-throws errors to the caller instead of calling
// fallbackToDefaultMode directly, to avoid infinite recursion since
// fallbackToDefaultMode calls this method. The caller's try/catch
// handles the fallback flow.
const normalizedConfig = normalizeThemeConfig(theme);
this.globalTheme.setConfig(normalizedConfig);
// Load custom fonts if specified, mirroring applyTheme() behavior
const fontUrls = (normalizedConfig?.token as Record<string, unknown>)
?.fontUrls as string[] | undefined;
this.loadFonts(fontUrls);
}
/**
* Loads custom fonts from theme configuration.
* Injects CSS @import statements for font URLs that haven't been loaded yet.
@@ -896,14 +945,17 @@ export class ThemeController {
/**
* Fetches a theme configuration from the CRUD API.
* @param themeId - The ID of the theme to fetch
* @returns The theme configuration or null if not found
* @returns The theme configuration or null if fetch fails
*/
private async fetchCrudTheme(
themeId: string,
): Promise<AnyThemeConfig | null> {
try {
// Use SupersetClient for proper authentication handling
const getTheme = makeApi<void, { result: { json_data: string } }>({
const getTheme = makeApi<
void,
{ result: { json_data: string; theme_name?: string } }
>({
method: 'GET',
endpoint: `/api/v1/theme/${themeId}`,
});
@@ -911,10 +963,65 @@ export class ThemeController {
const { result } = await getTheme();
const themeConfig = JSON.parse(result.json_data);
if (!themeConfig || typeof themeConfig !== 'object') {
console.error(`Invalid theme configuration for theme ${themeId}`);
return null;
}
// Return theme as-is
// Invalid tokens will be handled by Ant Design at runtime
// Runtime errors will be caught by applyThemeWithRecovery()
return themeConfig;
} catch (error) {
console.error('Failed to fetch CRUD theme:', error);
return null;
}
}
/**
* Fetches a fresh system default theme from the API for runtime recovery.
* Tries multiple fallback strategies to find a valid theme.
*
* Note: Uses raw fetch() instead of SupersetClient because ThemeController
* initializes early in the app lifecycle, before SupersetClient is fully
* configured. This avoids boot-time circular dependencies.
*
* @returns The system default theme configuration or null if not found
*/
private async fetchSystemDefaultTheme(): Promise<AnyThemeConfig | null> {
try {
// Try to fetch theme marked as system default (is_system_default=true)
const defaultResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
);
if (defaultResponse.ok) {
const data = await defaultResponse.json();
if (data.result?.length > 0) {
const themeConfig = JSON.parse(data.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
}
// Fallback: Try to fetch system theme named 'THEME_DEFAULT'
const fallbackResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
);
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json();
if (fallbackData.result?.length > 0) {
const themeConfig = JSON.parse(fallbackData.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
}
} catch (error) {
// Log for debugging but don't fail - fallback to cached theme will be used
console.warn('Failed to fetch system default theme:', error);
}
return null;
}
}

View File

@@ -0,0 +1,133 @@
/**
* 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 { renderHook } from '@testing-library/react-hooks';
import { useThemeValidation } from './useThemeValidation';
test('useThemeValidation validates valid theme with standard tokens', () => {
const validTheme = JSON.stringify({
token: {
colorPrimary: '#1890ff',
fontSize: 14,
},
});
const { result } = renderHook(() => useThemeValidation(validTheme));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation shows warnings for unknown tokens', () => {
const themeWithUnknownToken = JSON.stringify({
token: {
colorPrimary: '#1890ff',
unknownToken: 'value',
},
});
const { result } = renderHook(() =>
useThemeValidation(themeWithUnknownToken),
);
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(true);
expect(result.current.annotations.length).toBeGreaterThan(0);
expect(result.current.annotations[0].type).toBe('warning');
});
test('useThemeValidation shows error for empty theme', () => {
const emptyTheme = JSON.stringify({});
const { result } = renderHook(() => useThemeValidation(emptyTheme));
expect(result.current.hasErrors).toBe(true);
expect(result.current.annotations.length).toBeGreaterThan(0);
expect(result.current.annotations[0].type).toBe('error');
expect(result.current.annotations[0].text).toContain('cannot be empty');
});
test('useThemeValidation shows error for invalid JSON syntax', () => {
const invalidJson = '{invalid json}';
const { result } = renderHook(() => useThemeValidation(invalidJson));
expect(result.current.hasErrors).toBe(true);
expect(result.current.annotations.length).toBeGreaterThan(0);
expect(result.current.annotations[0].type).toBe('error');
});
test('useThemeValidation skips validation for empty string', () => {
const { result } = renderHook(() => useThemeValidation(''));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation validates Superset custom tokens', () => {
const themeWithCustomToken = JSON.stringify({
token: {
brandLogoUrl: '/static/logo.png',
brandSpinnerSvg: '<svg></svg>',
},
});
const { result } = renderHook(() => useThemeValidation(themeWithCustomToken));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation allows theme with only algorithm', () => {
const themeWithAlgorithm = JSON.stringify({
algorithm: 'dark',
});
const { result } = renderHook(() => useThemeValidation(themeWithAlgorithm));
expect(result.current.hasErrors).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation shows warning for null token value', () => {
const themeWithNullValue = JSON.stringify({
token: {
colorPrimary: null,
},
});
const { result } = renderHook(() => useThemeValidation(themeWithNullValue));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(true);
expect(result.current.annotations[0].type).toBe('warning');
expect(result.current.annotations[0].text).toContain('null/undefined');
});
test('useThemeValidation respects enabled option', () => {
const invalidJson = '{invalid}';
const { result } = renderHook(() =>
useThemeValidation(invalidJson, { enabled: false }),
);
expect(result.current.annotations).toHaveLength(0);
});

View File

@@ -0,0 +1,155 @@
/**
* 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 { useMemo, useState, useEffect } from 'react';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import type { JsonValidationAnnotation } from '@superset-ui/core/components/AsyncAceEditor';
import type { AnyThemeConfig } from '@apache-superset/core/ui';
import { validateTheme } from '../utils/themeStructureValidation';
/**
* Find the line number where a specific token appears in JSON string.
* Uses improved logic to handle nested objects and avoid false positives.
*/
function findTokenLineInJson(jsonString: string, tokenName: string): number {
if (!jsonString || !tokenName) {
return 0;
}
// Handle special _root token for structural errors
if (tokenName === '_root') {
return 0;
}
const lines = jsonString.split('\n');
// Look for the token name as a JSON property key
// Pattern: "tokenName" followed by : (with possible whitespace)
const propertyPattern = new RegExp(
`"${tokenName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*:`,
);
for (let i = 0; i < lines.length; i += 1) {
if (propertyPattern.test(lines[i].trim())) {
return i; // Return 0-based line number for AceEditor
}
}
// Fallback: simple string search for edge cases
const searchPattern = `"${tokenName}"`;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].includes(searchPattern)) {
return i;
}
}
// If token not found, return line 0
return 0;
}
export interface ThemeValidationResult {
annotations: JsonValidationAnnotation[];
hasErrors: boolean; // true if errors exist (blocks save)
hasWarnings: boolean; // true if warnings exist (non-blocking)
}
export interface UseThemeValidationOptions {
/** Whether to enable validation. Default: true */
enabled?: boolean;
/** Debounce delay in milliseconds for validation. Default: 300 */
debounceMs?: number;
}
/**
* Theme validation hook with live feedback.
* - Errors (JSON syntax, empty theme) block save/apply
* - Warnings (unknown tokens, null values) allow save/apply
*
* This hook validates structure and token names only.
* Token values are validated by Ant Design at runtime.
*/
export function useThemeValidation(
jsonValue?: string,
options: UseThemeValidationOptions = {},
): ThemeValidationResult {
const { enabled = true, debounceMs = 300 } = options;
const [debouncedValue, setDebouncedValue] = useState(jsonValue);
// Debounce for performance
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(jsonValue), debounceMs);
return () => clearTimeout(timer);
}, [jsonValue, debounceMs]);
// JSON syntax validation (ERRORS)
const jsonAnnotations = useJsonValidation(debouncedValue, {
enabled,
errorPrefix: 'Invalid JSON',
});
// Theme structure validation (ERRORS + WARNINGS)
const themeAnnotations = useMemo(() => {
// Skip if disabled or JSON is invalid
if (!enabled || jsonAnnotations.length > 0 || !debouncedValue?.trim()) {
return [];
}
try {
const config: AnyThemeConfig = JSON.parse(debouncedValue);
const result = validateTheme(config);
const annotations: JsonValidationAnnotation[] = [];
// Convert errors to annotations (blocks save)
result.errors.forEach(issue => {
annotations.push({
type: 'error',
row: findTokenLineInJson(debouncedValue, issue.tokenName),
column: 0,
text: issue.message,
});
});
// Convert warnings to annotations (non-blocking)
result.warnings.forEach(issue => {
annotations.push({
type: 'warning',
row: findTokenLineInJson(debouncedValue, issue.tokenName),
column: 0,
text: issue.message,
});
});
return annotations;
} catch {
// JSON parsing error already caught by jsonAnnotations
return [];
}
}, [enabled, debouncedValue, jsonAnnotations]);
return useMemo(() => {
const allAnnotations = [...jsonAnnotations, ...themeAnnotations];
return {
annotations: allAnnotations,
hasErrors: allAnnotations.some(a => a.type === 'error'),
hasWarnings: allAnnotations.some(a => a.type === 'warning'),
};
}, [jsonAnnotations, themeAnnotations]);
}

View File

@@ -833,6 +833,202 @@ test('ThemeController handles theme application errors', () => {
fallbackSpy.mockRestore();
});
test('ThemeController constructor recovers from corrupted stored theme', () => {
// Simulate corrupted dev theme override in storage
const corruptedTheme = { token: { colorPrimary: '#ff0000' } };
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'superset-dev-theme-override') {
return JSON.stringify(corruptedTheme);
}
return null;
});
// Mock Theme.fromConfig to return object with toSerializedConfig
mockThemeFromConfig.mockReturnValue({
...mockThemeObject,
toSerializedConfig: () => corruptedTheme,
});
// First call throws (corrupted theme), second call succeeds (fallback)
let callCount = 0;
mockSetConfig.mockImplementation(() => {
callCount += 1;
if (callCount === 1) {
throw new Error('Invalid theme configuration');
}
});
// Should not throw - constructor should recover
const controller = createController();
// Verify recovery happened - use shared consoleSpy to avoid interfering with other tests
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to apply stored theme, clearing invalid overrides:',
expect.any(Error),
);
// Verify invalid overrides were cleared from storage
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
// Verify controller is in a valid state
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
});
test('recovery flow: fetchSystemDefaultTheme returns theme → applies fetched theme', async () => {
// Test: fallbackToDefaultMode fetches theme from API and applies it
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme → applyThemeWithRecovery
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to return a system default theme from API
const systemTheme = { token: { colorPrimary: '#recovery-theme' } };
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(systemTheme) }],
}),
});
global.fetch = mockFetch;
// Track setConfig calls to verify the fetched theme is applied
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
});
// Trigger fallbackToDefaultMode (simulates what happens after applyTheme fails)
await (controller as any).fallbackToDefaultMode();
// Verify API was called to fetch system default theme
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
);
// Verify the fetched theme was applied via applyThemeWithRecovery
expect(setConfigCalls.length).toBe(1);
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({ colorPrimary: '#recovery-theme' }),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
test('recovery flow: both API fetches fail → falls back to cached default theme', async () => {
// Test: When fetchSystemDefaultTheme fails, fallbackToDefaultMode uses cached theme
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme (fails) → applyTheme(cached)
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to fail for both API endpoints
const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
global.fetch = mockFetch;
// Track setConfig calls
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
});
// Trigger fallbackToDefaultMode
await (controller as any).fallbackToDefaultMode();
// Verify fetch was attempted
expect(mockFetch).toHaveBeenCalled();
// Verify fallback to cached default theme was applied via applyTheme
expect(setConfigCalls.length).toBe(1);
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed', // From DEFAULT_THEME in test setup
}),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
test('recovery flow: fetched theme fails to apply → falls back to cached default', async () => {
// Test: When applyThemeWithRecovery fails, fallbackToDefaultMode uses cached theme
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme → applyThemeWithRecovery (fails) → applyTheme(cached)
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to return a theme
const systemTheme = { token: { colorPrimary: '#bad-theme' } };
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(systemTheme) }],
}),
});
global.fetch = mockFetch;
// First setConfig call (applyThemeWithRecovery) fails, second (applyTheme) succeeds
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
if (setConfigCalls.length === 1) {
throw new Error('Fetched theme failed to apply');
}
});
// Trigger fallbackToDefaultMode
await (controller as any).fallbackToDefaultMode();
// Verify fetch was called
expect(mockFetch).toHaveBeenCalled();
// Verify both attempts were made: fetched theme (failed) then cached default
expect(setConfigCalls.length).toBe(2);
// First call was the fetched theme (which failed)
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({ colorPrimary: '#bad-theme' }),
}),
);
// Second call was the cached default theme
expect(setConfigCalls[1]).toEqual(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed', // From DEFAULT_THEME
}),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
// Cleanup tests
test('ThemeController cleans up listeners on destroy', () => {
const mockMediaQueryInstance = {

View File

@@ -0,0 +1,108 @@
/**
* 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 {
isValidTokenName,
isSupersetCustomToken,
getAllValidTokenNames,
} from './antdTokenNames';
test('isValidTokenName recognizes standard Ant Design tokens', () => {
expect(isValidTokenName('colorPrimary')).toBe(true);
expect(isValidTokenName('fontSize')).toBe(true);
expect(isValidTokenName('padding')).toBe(true);
expect(isValidTokenName('borderRadius')).toBe(true);
});
test('isValidTokenName recognizes Superset custom tokens', () => {
expect(isValidTokenName('brandLogoUrl')).toBe(true);
expect(isValidTokenName('brandSpinnerSvg')).toBe(true);
expect(isValidTokenName('fontSizeXS')).toBe(true);
expect(isValidTokenName('echartsOptionsOverrides')).toBe(true);
});
test('isValidTokenName rejects unknown tokens', () => {
expect(isValidTokenName('fooBarBaz')).toBe(false);
expect(isValidTokenName('colrPrimary')).toBe(false);
expect(isValidTokenName('invalidToken')).toBe(false);
});
test('isValidTokenName handles edge cases', () => {
expect(isValidTokenName('')).toBe(false);
expect(isValidTokenName(' ')).toBe(false);
});
test('isSupersetCustomToken identifies Superset-specific tokens', () => {
expect(isSupersetCustomToken('brandLogoUrl')).toBe(true);
expect(isSupersetCustomToken('brandSpinnerSvg')).toBe(true);
expect(isSupersetCustomToken('fontSizeXS')).toBe(true);
expect(isSupersetCustomToken('fontUrls')).toBe(true);
});
test('isSupersetCustomToken returns false for Ant Design tokens', () => {
expect(isSupersetCustomToken('colorPrimary')).toBe(false);
expect(isSupersetCustomToken('fontSize')).toBe(false);
});
test('isSupersetCustomToken returns false for unknown tokens', () => {
expect(isSupersetCustomToken('fooBar')).toBe(false);
});
test('getAllValidTokenNames returns categorized token names', () => {
const result = getAllValidTokenNames();
expect(result).toHaveProperty('antdTokens');
expect(result).toHaveProperty('supersetTokens');
expect(result).toHaveProperty('total');
});
test('getAllValidTokenNames has reasonable token counts', () => {
const result = getAllValidTokenNames();
// Ant Design tokens should exist (avoid brittle exact count that breaks on upgrades)
expect(result.antdTokens.length).toBeGreaterThan(0);
expect(result.antdTokens).toContain('colorPrimary');
expect(result.antdTokens).toContain('fontSize');
expect(result.antdTokens).toContain('borderRadius');
// Superset custom tokens should exist
expect(result.supersetTokens.length).toBeGreaterThan(0);
expect(result.supersetTokens).toContain('brandLogoUrl');
expect(result.supersetTokens).toContain('fontUrls');
// Total should be sum of both
expect(result.total).toBe(
result.antdTokens.length + result.supersetTokens.length,
);
});
test('getAllValidTokenNames includes known Superset tokens', () => {
const result = getAllValidTokenNames();
expect(result.supersetTokens).toContain('brandLogoUrl');
expect(result.supersetTokens).toContain('brandSpinnerSvg');
expect(result.supersetTokens).toContain('fontSizeXS');
});
test('getAllValidTokenNames includes known Ant Design tokens', () => {
const result = getAllValidTokenNames();
expect(result.antdTokens).toContain('colorPrimary');
expect(result.antdTokens).toContain('fontSize');
expect(result.antdTokens).toContain('padding');
});

View File

@@ -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 { theme } from 'antd';
/**
* Superset-specific custom tokens that extend Ant Design's token system.
* These keys are derived from the SupersetSpecificTokens interface to ensure consistency.
*/
const SUPERSET_CUSTOM_TOKENS: Set<string> = new Set([
// Font extensions (fontWeightStrong is an Ant Design token, not Superset-specific)
'fontSizeXS',
'fontSizeXXL',
'fontWeightNormal',
'fontWeightLight',
// Brand tokens
'brandIconMaxWidth',
'brandLogoAlt',
'brandLogoUrl',
'brandLogoMargin',
'brandLogoHref',
'brandLogoHeight',
// Spinner tokens
'brandSpinnerUrl',
'brandSpinnerSvg',
// ECharts tokens
'echartsOptionsOverrides',
'echartsOptionsOverridesByChartType',
// Font loading
'fontUrls',
]);
/**
* Lazy-loaded cache of valid token names.
* Combines Ant Design tokens (extracted at runtime) + Superset custom tokens.
*/
let validTokenNamesCache: Set<string> | undefined;
/**
* Get all valid token names (Ant Design + Superset custom).
* Uses lazy loading and caching for performance.
*/
function getValidTokenNames(): Set<string> {
if (validTokenNamesCache === undefined) {
// Extract all token names from Ant Design's default theme
const antdTokens = theme.getDesignToken();
const antdTokenNames = Object.keys(antdTokens);
// Combine with Superset custom tokens
validTokenNamesCache = new Set([
...antdTokenNames,
...SUPERSET_CUSTOM_TOKENS,
]);
}
return validTokenNamesCache;
}
/**
* Check if a token name is valid (recognized by Ant Design OR Superset).
* @param tokenName - The token name to validate
* @returns true if the token is recognized, false otherwise
*/
export function isValidTokenName(tokenName: string): boolean {
return getValidTokenNames().has(tokenName);
}
/**
* Check if a token is a Superset custom token (not from Ant Design).
* @param tokenName - The token name to check
* @returns true if it's a Superset-specific token
*/
export function isSupersetCustomToken(tokenName: string): boolean {
return SUPERSET_CUSTOM_TOKENS.has(tokenName);
}
/**
* Get all valid token names, categorized by source.
* Useful for debugging and testing.
*/
export function getAllValidTokenNames(): {
antdTokens: string[];
supersetTokens: string[];
total: number;
} {
const allTokens = getValidTokenNames();
const antdTokens = Array.from(allTokens).filter(
t => !isSupersetCustomToken(t),
);
const supersetTokens: string[] = Array.from(SUPERSET_CUSTOM_TOKENS);
return {
antdTokens,
supersetTokens,
total: allTokens.size,
};
}

View File

@@ -0,0 +1,346 @@
/**
* 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 type { AnyThemeConfig } from '@apache-superset/core/ui';
import { validateTheme } from './themeStructureValidation';
test('validateTheme validates a valid theme with standard tokens', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
fontSize: 14,
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
test('validateTheme validates a theme with Superset custom tokens', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
brandLogoUrl: '/static/logo.png',
brandSpinnerSvg: '<svg></svg>',
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
test('validateTheme warns about unknown token names', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
fooBarBaz: 'invalid',
colrPrimary: '#ff0000', // Typo
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true); // Warnings don't block
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(2);
expect(result.warnings[0].tokenName).toBe('fooBarBaz');
expect(result.warnings[1].tokenName).toBe('colrPrimary');
expect(result.warnings[0].severity).toBe('warning');
});
test('validateTheme warns about null/undefined token values', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: null,
fontSize: undefined,
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.warnings).toHaveLength(2);
expect(result.warnings[0].message).toContain('null/undefined');
expect(result.warnings[1].message).toContain('null/undefined');
});
test('validateTheme errors on empty theme object', () => {
const theme: AnyThemeConfig = {};
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('cannot be empty');
});
test('validateTheme errors on null theme config', () => {
const result = validateTheme(null as any);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('must be a valid object');
});
test('validateTheme allows theme with only algorithm', () => {
const theme: AnyThemeConfig = {
algorithm: 'dark' as any,
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('validateTheme allows theme with only components', () => {
const theme: AnyThemeConfig = {
components: {
Button: {
colorPrimary: '#1890ff',
},
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('validateTheme errors on theme with empty token object but no algorithm or components', () => {
const theme: AnyThemeConfig = {
token: {},
};
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('cannot be empty');
});
test('validateTheme combines errors and warnings correctly', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
unknownToken: 'value',
nullToken: null,
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true); // No errors, just warnings
expect(result.errors).toHaveLength(0);
expect(result.warnings.length).toBeGreaterThan(0);
});
test('validateTheme errors when token is an array instead of object', () => {
const theme = {
token: ['colorPrimary', '#1890ff'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('must be an object');
});
test('validateTheme errors when token is a string instead of object', () => {
const theme = {
token: 'colorPrimary',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('must be an object');
});
test('validateTheme errors when components is an array instead of object', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
components: ['Button', 'Input'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Components configuration');
expect(result.errors[0].message).toContain('must be an object');
});
test('validateTheme errors when components is a primitive', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
components: 'Button',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Components configuration');
});
test('validateTheme errors when algorithm is a number', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: 123,
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Algorithm must be a string');
});
test('validateTheme errors when algorithm is an object', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: { type: 'dark' },
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Algorithm must be a string');
});
test('validateTheme allows algorithm as array of strings', () => {
const theme = {
algorithm: ['dark', 'compact'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('validateTheme errors when algorithm array contains non-strings', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: ['dark', 123, 'compact'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Algorithm must be a string');
});
test('validateTheme errors when token is explicitly null', () => {
const theme = {
token: null,
algorithm: 'dark',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('must be an object');
expect(result.errors[0].message).toContain('not null');
});
test('validateTheme errors when components is explicitly null', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
components: null,
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Components configuration');
expect(result.errors[0].message).toContain('not null');
});
test('validateTheme errors when algorithm is explicitly null', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: null,
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Algorithm cannot be null');
});
test('validateTheme errors when algorithm string is not a valid value', () => {
const theme = {
algorithm: 'invalid-algorithm',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Invalid algorithm value');
expect(result.errors[0].message).toContain('invalid-algorithm');
expect(result.errors[0].message).toContain('default, dark, system, compact');
});
test('validateTheme errors when algorithm array contains invalid values', () => {
const theme = {
algorithm: ['dark', 'invalid-mode', 'compact'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Invalid algorithm value');
expect(result.errors[0].message).toContain('invalid-mode');
});
test('validateTheme allows all valid algorithm values', () => {
const validAlgorithms = ['default', 'dark', 'system', 'compact'];
validAlgorithms.forEach(algo => {
const theme = { algorithm: algo } as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,191 @@
/**
* 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 type { AnyThemeConfig } from '@apache-superset/core/ui';
import { isValidTokenName } from './antdTokenNames';
/**
* Valid algorithm values that match backend ThemeMode enum.
* These correspond to Ant Design's built-in theme algorithms.
*/
const VALID_ALGORITHM_VALUES = new Set([
'default',
'dark',
'system',
'compact',
]);
export interface ValidationIssue {
tokenName: string;
severity: 'error' | 'warning';
message: string;
}
export interface ValidationResult {
valid: boolean; // false if ANY errors exist (warnings don't affect this)
errors: ValidationIssue[];
warnings: ValidationIssue[];
}
/**
* Validates theme structure and token names.
* - ERRORS block save/apply (invalid structure, empty themes)
* - WARNINGS allow save/apply but show in editor (unknown tokens, null values)
*
* This validation does NOT check token values - Ant Design handles that at runtime.
*/
export function validateTheme(themeConfig: AnyThemeConfig): ValidationResult {
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
// ERROR: Null/invalid config
if (!themeConfig || typeof themeConfig !== 'object') {
errors.push({
tokenName: '_root',
severity: 'error',
message: 'Theme configuration must be a valid object',
});
return { valid: false, errors, warnings };
}
// ERROR: Empty theme (no tokens, no algorithm, no components)
const hasTokens =
themeConfig.token && Object.keys(themeConfig.token).length > 0;
const hasAlgorithm = Boolean(themeConfig.algorithm);
const hasComponents =
themeConfig.components && Object.keys(themeConfig.components).length > 0;
if (!hasTokens && !hasAlgorithm && !hasComponents) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Theme cannot be empty. Add at least one token, algorithm, or component override.',
});
return { valid: false, errors, warnings };
}
// ERROR: token must be an object if present (null is also rejected by backend)
const rawToken = themeConfig.token;
if (rawToken !== undefined) {
if (
rawToken === null ||
typeof rawToken !== 'object' ||
Array.isArray(rawToken)
) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Token configuration must be an object, not null, array, or primitive',
});
return { valid: false, errors, warnings };
}
}
const tokens = rawToken ?? {};
// ERROR: components must be an object if present (null is also rejected by backend)
const rawComponents = themeConfig.components;
if (rawComponents !== undefined) {
if (
rawComponents === null ||
typeof rawComponents !== 'object' ||
Array.isArray(rawComponents)
) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Components configuration must be an object, not null, array, or primitive',
});
return { valid: false, errors, warnings };
}
}
// ERROR: algorithm must be a valid string or array of valid strings if present
// Valid values: "default", "dark", "system", "compact" (matches backend ThemeMode)
const rawAlgorithm = themeConfig.algorithm;
if (rawAlgorithm !== undefined) {
// Null is rejected by backend
if (rawAlgorithm === null) {
errors.push({
tokenName: '_root',
severity: 'error',
message: 'Algorithm cannot be null',
});
return { valid: false, errors, warnings };
}
// Must be string or array of strings
const isString = typeof rawAlgorithm === 'string';
const isStringArray =
Array.isArray(rawAlgorithm) &&
rawAlgorithm.every(a => typeof a === 'string');
if (!isString && !isStringArray) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Algorithm must be a string or array of strings (e.g., "dark" or ["dark", "compact"])',
});
return { valid: false, errors, warnings };
}
// Validate algorithm values against allowed set
const algorithms = isString ? [rawAlgorithm] : (rawAlgorithm as string[]);
const invalidAlgorithms = algorithms.filter(
a => !VALID_ALGORITHM_VALUES.has(a),
);
if (invalidAlgorithms.length > 0) {
errors.push({
tokenName: '_root',
severity: 'error',
message: `Invalid algorithm value(s): "${invalidAlgorithms.join('", "')}". Valid values are: default, dark, system, compact`,
});
return { valid: false, errors, warnings };
}
}
Object.entries(tokens).forEach(([name, value]) => {
// Null/undefined check
if (value === null || value === undefined) {
warnings.push({
tokenName: name,
severity: 'warning',
message: `Token '${name}' has null/undefined value`,
});
return;
}
// Token name validation
if (!isValidTokenName(name)) {
warnings.push({
tokenName: name,
severity: 'warning',
message: `Unknown token '${name}' - may be ignored by Ant Design`,
});
}
});
return {
valid: errors.length === 0,
errors,
warnings,
};
}

View File

@@ -936,7 +936,7 @@ THEME_DEFAULT: Theme = {
# Fonts
"fontUrls": [],
"fontFamily": "Inter, Helvetica, Arial, sans-serif",
"fontFamilyCode": "'Fira Code', 'Courier New', monospace",
"fontFamilyCode": "'IBM Plex Mono', 'Courier New', monospace",
# Extra tokens
"transitionTiming": 0.3,
"brandIconMaxWidth": 37,

View File

@@ -21,10 +21,12 @@ from datetime import datetime
from typing import Any, Dict, List
import dateutil.parser
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Query
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
from superset.daos.base import BaseDAO
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
from superset.extensions import db
from superset.models.core import Database
from superset.models.dashboard import Dashboard
@@ -35,8 +37,10 @@ from superset.views.base import DatasourceFilter
logger = logging.getLogger(__name__)
# Custom filterable fields for datasets
DATASET_CUSTOM_FIELDS: dict[str, list[str]] = {}
# Custom filterable fields for datasets (not direct model columns)
DATASET_CUSTOM_FIELDS: dict[str, list[str]] = {
"database_name": ["eq", "like", "ilike"],
}
class DatasetDAO(BaseDAO[SqlaTable]):
@@ -49,6 +53,37 @@ class DatasetDAO(BaseDAO[SqlaTable]):
base_filter = DatasourceFilter
@classmethod
def apply_column_operators(
cls,
query: Query,
column_operators: list[ColumnOperator] | None = None,
) -> Query:
"""Override to handle database_name filter via subquery on Database.
database_name lives on Database, not SqlaTable, so we intercept it
here and use a subquery to avoid duplicate joins with DatasourceFilter.
"""
if not column_operators:
return query
remaining_operators: list[ColumnOperator] = []
for c in column_operators:
if not isinstance(c, ColumnOperator):
c = ColumnOperator.model_validate(c)
if c.col == "database_name":
operator_enum = ColumnOperatorEnum(c.opr)
subq = select(Database.id).where(
operator_enum.apply(Database.database_name, c.value)
)
query = query.filter(SqlaTable.database_id.in_(subq))
else:
remaining_operators.append(c)
if remaining_operators:
query = super().apply_column_operators(query, remaining_operators)
return query
@staticmethod
def get_database_by_id(database_id: int) -> Database | None:
try:

View File

@@ -0,0 +1,49 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.db_engine_specs.base import DatabaseCategory
from superset.db_engine_specs.mysql import MySQLEngineSpec
class DoltDBEngineSpec(MySQLEngineSpec):
"""
Engine spec for DoltDB.
DoltDB is a SQL database with Git-like version control for data and schema.
It is fully MySQL-compatible.
"""
engine = "doltdb"
engine_name = "DoltDB"
metadata = {
"description": (
"DoltDB is a SQL database with Git-like version control for data "
"and schema. It is fully MySQL-compatible."
),
"logo": "doltdb.png",
"homepage_url": "https://www.dolthub.com/",
"categories": [
DatabaseCategory.TRADITIONAL_RDBMS,
DatabaseCategory.OPEN_SOURCE,
],
"pypi_packages": ["mysqlclient"],
"connection_string": "mysql://{username}:{password}@{host}:{port}/{database}",
"default_port": 3306,
"notes": (
"DoltDB uses the MySQL wire protocol. Connect using any MySQL driver."
),
}

View File

@@ -57,7 +57,7 @@ class MssqlEngineSpec(BaseEngineSpec):
"description": (
"Microsoft SQL Server is a relational database management system."
),
"logo": "msql.png",
"logo": "mssql-server.png",
"homepage_url": "https://www.microsoft.com/en-us/sql-server",
"categories": [
DatabaseCategory.TRADITIONAL_RDBMS,

View File

@@ -0,0 +1,53 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.db_engine_specs.base import DatabaseCategory
from superset.db_engine_specs.postgres import PostgresBaseEngineSpec
class PostGISEngineSpec(PostgresBaseEngineSpec):
"""
Engine spec for PostGIS.
PostGIS is a spatial database extender for PostgreSQL, adding support for
geographic objects and location queries.
"""
engine = "postgis"
engine_name = "PostGIS"
default_driver = "psycopg2"
metadata = {
"description": (
"PostGIS is a spatial database extender for PostgreSQL, adding "
"support for geographic objects and location queries."
),
"logo": "postgis.svg",
"homepage_url": "https://postgis.net/",
"categories": [
DatabaseCategory.TRADITIONAL_RDBMS,
DatabaseCategory.OPEN_SOURCE,
],
"pypi_packages": ["psycopg2"],
"connection_string": (
"postgresql://{username}:{password}@{host}:{port}/{database}"
),
"default_port": 5432,
"notes": (
"PostGIS extends PostgreSQL with geospatial capabilities. "
"Uses the standard PostgreSQL driver."
),
}

View File

@@ -0,0 +1,62 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
class QuestDBEngineSpec(BaseEngineSpec):
"""
Engine spec for QuestDB.
QuestDB is a high-performance, open-source time-series database optimized
for fast ingest and SQL queries.
"""
engine = "questdb"
engine_name = "QuestDB"
default_driver = "questdb"
metadata = {
"description": (
"QuestDB is a high-performance, open-source time-series database "
"optimized for fast ingest and SQL queries."
),
"logo": "questdb.png",
"homepage_url": "https://questdb.io/",
"categories": [
DatabaseCategory.ANALYTICAL_DATABASES,
DatabaseCategory.OPEN_SOURCE,
],
"pypi_packages": ["questdb-connect"],
"connection_string": "questdb://{username}:{password}@{host}:{port}/{database}",
"default_port": 8812,
"notes": (
"QuestDB is optimized for time-series data. Install questdb-connect "
"for SQLAlchemy support."
),
}
_time_grain_expressions = {
None: "{col}",
TimeGrain.SECOND: "timestamp_floor('s', {col})",
TimeGrain.MINUTE: "timestamp_floor('m', {col})",
TimeGrain.HOUR: "timestamp_floor('h', {col})",
TimeGrain.DAY: "timestamp_floor('d', {col})",
TimeGrain.WEEK: "timestamp_floor('w', {col})",
TimeGrain.MONTH: "timestamp_floor('M', {col})",
TimeGrain.YEAR: "timestamp_floor('y', {col})",
}

View File

@@ -0,0 +1,74 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.db_engine_specs.base import DatabaseCategory
from superset.db_engine_specs.mysql import MySQLEngineSpec
class TiDBEngineSpec(MySQLEngineSpec):
"""
Engine spec for TiDB.
TiDB is an open-source, cloud-native, distributed SQL database designed for
hybrid transactional and analytical processing (HTAP) workloads. It is
MySQL-compatible.
"""
engine = "tidb"
engine_name = "TiDB"
metadata = {
"description": (
"TiDB is an open-source, cloud-native, distributed SQL database "
"designed for hybrid transactional and analytical processing (HTAP) "
"workloads. It is MySQL-compatible."
),
"logo": "tidb.svg",
"homepage_url": "https://www.pingcap.com/tidb/",
"categories": [
DatabaseCategory.TRADITIONAL_RDBMS,
DatabaseCategory.OPEN_SOURCE,
],
"pypi_packages": ["mysqlclient", "sqlalchemy-tidb"],
"connection_string": "mysql://{username}:{password}@{host}:{port}/{database}",
"default_port": 4000,
"drivers": [
{
"name": "mysqlclient",
"pypi_package": "mysqlclient",
"connection_string": (
"mysql://{username}:{password}@{host}:{port}/{database}"
),
"is_recommended": True,
"notes": (
"Standard MySQL driver, works with TiDB's MySQL compatibility."
),
},
{
"name": "tidb",
"pypi_package": "sqlalchemy-tidb",
"connection_string": (
"tidb://{username}:{password}@{host}:{port}/{database}"
),
"is_recommended": False,
"notes": "Native TiDB dialect with TiDB-specific optimizations.",
},
],
"notes": (
"TiDB is MySQL-compatible. Use the standard MySQL driver or the "
"native sqlalchemy-tidb dialect."
),
}

View File

@@ -0,0 +1,63 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
class TimeplusEngineSpec(BaseEngineSpec):
"""
Engine spec for Timeplus.
Timeplus is a streaming-first analytics platform that provides real-time
data processing with SQL.
"""
engine = "timeplus"
engine_name = "Timeplus"
default_driver = "timeplus"
metadata = {
"description": (
"Timeplus is a streaming-first analytics platform that provides "
"real-time data processing with SQL."
),
"logo": "timeplus.svg",
"homepage_url": "https://www.timeplus.com/",
"categories": [
DatabaseCategory.ANALYTICAL_DATABASES,
DatabaseCategory.OPEN_SOURCE,
],
"pypi_packages": ["timeplus-connect"],
"connection_string": "timeplus://{username}:{password}@{host}:{port}",
"default_port": 8123,
"notes": (
"Timeplus provides real-time streaming SQL analytics. Install "
"timeplus-connect for SQLAlchemy and Superset support."
),
}
_time_grain_expressions = {
None: "{col}",
TimeGrain.SECOND: "date_trunc('second', {col})",
TimeGrain.MINUTE: "date_trunc('minute', {col})",
TimeGrain.HOUR: "date_trunc('hour', {col})",
TimeGrain.DAY: "date_trunc('day', {col})",
TimeGrain.WEEK: "date_trunc('week', {col})",
TimeGrain.MONTH: "date_trunc('month', {col})",
TimeGrain.QUARTER: "date_trunc('quarter', {col})",
TimeGrain.YEAR: "date_trunc('year', {col})",
}

View File

@@ -25,6 +25,7 @@ from urllib.parse import parse_qs, urlparse
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.auth import has_dataset_access
from superset.mcp_service.chart.chart_utils import (
analyze_chart_capabilities,
@@ -132,23 +133,24 @@ async def generate_chart( # noqa: C901
await ctx.debug(
"Validating chart request: dataset_id=%s" % (request.dataset_id,)
)
from superset.mcp_service.chart.validation import ValidationPipeline
with event_logger.log_context(action="mcp.generate_chart.validation"):
from superset.mcp_service.chart.validation import ValidationPipeline
validation_result = ValidationPipeline.validate_request_with_warnings(
request.model_dump()
)
validation_result = ValidationPipeline.validate_request_with_warnings(
request.model_dump()
)
if validation_result.is_valid and validation_result.request is not None:
# Use the validated request going forward
request = validation_result.request
if validation_result.is_valid and validation_result.request is not None:
# Use the validated request going forward
request = validation_result.request
# Capture runtime warnings (informational, not blocking)
if validation_result.warnings:
runtime_warnings = validation_result.warnings.get("warnings", [])
if runtime_warnings:
await ctx.info(
"Runtime suggestions: %s" % ("; ".join(runtime_warnings[:3]),)
)
# Capture runtime warnings (informational, not blocking)
if validation_result.warnings:
runtime_warnings = validation_result.warnings.get("warnings", [])
if runtime_warnings:
await ctx.info(
"Runtime suggestions: %s" % ("; ".join(runtime_warnings[:3]),)
)
if not validation_result.is_valid:
execution_time = int((time.time() - start_time) * 1000)
@@ -197,35 +199,38 @@ async def generate_chart( # noqa: C901
from superset.daos.dataset import DatasetDAO
await ctx.debug("Looking up dataset: dataset_id=%s" % (request.dataset_id,))
dataset = None
if isinstance(request.dataset_id, int) or (
isinstance(request.dataset_id, str) and request.dataset_id.isdigit()
):
dataset_id = (
int(request.dataset_id)
if isinstance(request.dataset_id, str)
else request.dataset_id
)
dataset = DatasetDAO.find_by_id(dataset_id)
# SECURITY FIX: Also validate permissions for numeric ID access
if dataset and not has_dataset_access(dataset):
logger.warning(
"User %s attempted to access dataset %s without permission",
ctx.user.username if hasattr(ctx, "user") else "unknown",
dataset_id,
with event_logger.log_context(action="mcp.generate_chart.dataset_lookup"):
dataset = None
if isinstance(request.dataset_id, int) or (
isinstance(request.dataset_id, str) and request.dataset_id.isdigit()
):
dataset_id = (
int(request.dataset_id)
if isinstance(request.dataset_id, str)
else request.dataset_id
)
dataset = None # Treat as not found
else:
# SECURITY FIX: Try UUID lookup with permission validation
dataset = DatasetDAO.find_by_id(request.dataset_id, id_column="uuid")
# Validate permissions for UUID-based access
if dataset and not has_dataset_access(dataset):
logger.warning(
"User %s attempted access dataset %s via UUID",
ctx.user.username if hasattr(ctx, "user") else "unknown",
request.dataset_id,
dataset = DatasetDAO.find_by_id(dataset_id)
# SECURITY FIX: Also validate permissions for numeric ID access
if dataset and not has_dataset_access(dataset):
logger.warning(
"User %s attempted to access dataset %s without permission",
ctx.user.username if hasattr(ctx, "user") else "unknown",
dataset_id,
)
dataset = None # Treat as not found
else:
# SECURITY FIX: Try UUID lookup with permission validation
dataset = DatasetDAO.find_by_id(
request.dataset_id, id_column="uuid"
)
dataset = None # Treat as not found
# Validate permissions for UUID-based access
if dataset and not has_dataset_access(dataset):
logger.warning(
"User %s attempted access dataset %s via UUID",
ctx.user.username if hasattr(ctx, "user") else "unknown",
request.dataset_id,
)
dataset = None # Treat as not found
if not dataset:
await ctx.error(
@@ -267,22 +272,25 @@ async def generate_chart( # noqa: C901
)
try:
command = CreateChartCommand(
{
"slice_name": chart_name,
"viz_type": form_data["viz_type"],
"datasource_id": dataset.id,
"datasource_type": "table",
"params": json.dumps(form_data),
}
)
with event_logger.log_context(action="mcp.generate_chart.db_write"):
command = CreateChartCommand(
{
"slice_name": chart_name,
"viz_type": form_data["viz_type"],
"datasource_id": dataset.id,
"datasource_type": "table",
"params": json.dumps(form_data),
}
)
chart = command.run()
chart_id = chart.id
chart = command.run()
chart_id = chart.id
# Ensure chart was created successfully before committing
if not chart or not chart.id:
raise Exception("Chart creation failed - no chart ID returned")
# Ensure chart was created successfully before committing
if not chart or not chart.id:
raise RuntimeError(
"Chart creation failed - no chart ID returned"
)
await ctx.info(
"Chart created successfully: chart_id=%s, chart_name=%s"
@@ -301,35 +309,39 @@ async def generate_chart( # noqa: C901
# Generate form_data_key for saved charts (needed for chatbot rendering)
try:
from superset.commands.explore.form_data.parameters import (
CommandParameters,
)
from superset.mcp_service.commands.create_form_data import (
MCPCreateFormDataCommand,
)
from superset.utils.core import DatasourceType
with event_logger.log_context(
action="mcp.generate_chart.form_data_cache"
):
from superset.commands.explore.form_data.parameters import (
CommandParameters,
)
from superset.mcp_service.commands.create_form_data import (
MCPCreateFormDataCommand,
)
from superset.utils.core import DatasourceType
# Add datasource to form_data for the cache
form_data_with_datasource = {
**form_data,
"datasource": f"{dataset.id}__table",
}
# Add datasource to form_data for the cache
form_data_with_datasource = {
**form_data,
"datasource": f"{dataset.id}__table",
}
cmd_params = CommandParameters(
datasource_type=DatasourceType.TABLE,
datasource_id=dataset.id,
chart_id=chart.id,
tab_id=None,
form_data=json.dumps(form_data_with_datasource),
)
form_data_key = MCPCreateFormDataCommand(cmd_params).run()
await ctx.debug(
"Generated form_data_key for saved chart: form_data_key=%s"
% (form_data_key,)
)
cmd_params = CommandParameters(
datasource_type=DatasourceType.TABLE,
datasource_id=dataset.id,
chart_id=chart.id,
tab_id=None,
form_data=json.dumps(form_data_with_datasource),
)
form_data_key = MCPCreateFormDataCommand(cmd_params).run()
await ctx.debug(
"Generated form_data_key for saved chart: "
"form_data_key=%s" % (form_data_key,)
)
except Exception as fdk_error:
logger.warning(
"Failed to generate form_data_key for saved chart: %s", fdk_error
"Failed to generate form_data_key for saved chart: %s",
fdk_error,
)
await ctx.warning(
"Failed to generate form_data_key: error=%s" % (str(fdk_error),)
@@ -383,60 +395,66 @@ async def generate_chart( # noqa: C901
"Generating previews: formats=%s" % (str(request.preview_formats),)
)
try:
for format_type in request.preview_formats:
await ctx.debug(
"Processing preview format: format=%s" % (format_type,)
)
if chart_id:
# For saved charts, use the existing preview generation
from superset.mcp_service.chart.tool.get_chart_preview import (
_get_chart_preview_internal,
GetChartPreviewRequest,
with event_logger.log_context(action="mcp.generate_chart.preview"):
for format_type in request.preview_formats:
await ctx.debug(
"Processing preview format: format=%s" % (format_type,)
)
preview_request = GetChartPreviewRequest(
identifier=str(chart_id), format=format_type
)
preview_result = await _get_chart_preview_internal(
preview_request, ctx
)
if hasattr(preview_result, "content"):
previews[format_type] = preview_result.content
else:
# For preview-only mode (save_chart=false)
# Note: Screenshot-based URL previews are not supported.
# Use the explore_url to view the chart interactively.
if format_type in ["ascii", "table", "vega_lite"]:
# Generate preview from form data without saved chart
from superset.mcp_service.chart.preview_utils import (
generate_preview_from_form_data,
if chart_id:
# For saved charts, use the existing preview
from superset.mcp_service.chart.tool.get_chart_preview import ( # noqa: E501
_get_chart_preview_internal,
GetChartPreviewRequest,
)
# Convert dataset_id to int only if it's numeric
if (
isinstance(request.dataset_id, str)
and request.dataset_id.isdigit()
):
dataset_id_for_preview = int(request.dataset_id)
elif isinstance(request.dataset_id, int):
dataset_id_for_preview = request.dataset_id
else:
# Skip preview generation for non-numeric dataset IDs
logger.warning(
"Cannot generate preview for non-numeric "
preview_request = GetChartPreviewRequest(
identifier=str(chart_id), format=format_type
)
preview_result = await _get_chart_preview_internal(
preview_request, ctx
)
if hasattr(preview_result, "content"):
previews[format_type] = preview_result.content
else:
# For preview-only mode (save_chart=false)
# Note: Screenshot-based URL previews are not
# supported. Use explore_url to view interactively.
if format_type in [
"ascii",
"table",
"vega_lite",
]:
# Generate preview from form data
from superset.mcp_service.chart.preview_utils import (
generate_preview_from_form_data,
)
continue
preview_result = generate_preview_from_form_data(
form_data=form_data,
dataset_id=dataset_id_for_preview,
preview_format=format_type,
)
# Convert dataset_id to int only if numeric
if (
isinstance(request.dataset_id, str)
and request.dataset_id.isdigit()
):
dataset_id_for_preview = int(request.dataset_id)
elif isinstance(request.dataset_id, int):
dataset_id_for_preview = request.dataset_id
else:
# Skip for non-numeric dataset IDs
logger.warning(
"Cannot generate preview for"
" non-numeric dataset IDs"
)
continue
if not hasattr(preview_result, "error"):
previews[format_type] = preview_result
preview_result = generate_preview_from_form_data(
form_data=form_data,
dataset_id=dataset_id_for_preview,
preview_format=format_type,
)
if not hasattr(preview_result, "error"):
previews[format_type] = preview_result
except Exception as e:
# Log warning but don't fail the entire request

View File

@@ -29,6 +29,7 @@ from superset_core.mcp import tool
if TYPE_CHECKING:
from superset.models.slice import Slice
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import (
ChartData,
ChartError,
@@ -82,25 +83,27 @@ async def get_chart_data( # noqa: C901
from superset.utils import json as utils_json
# Find the chart
chart = None
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
):
chart_id = (
int(request.identifier)
if isinstance(request.identifier, str)
else request.identifier
)
await ctx.debug(
"Performing ID-based chart lookup: chart_id=%s" % (chart_id,)
)
chart = ChartDAO.find_by_id(chart_id)
else:
await ctx.debug(
"Performing UUID-based chart lookup: uuid=%s" % (request.identifier,)
)
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
with event_logger.log_context(action="mcp.get_chart_data.chart_lookup"):
chart = None
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
):
chart_id = (
int(request.identifier)
if isinstance(request.identifier, str)
else request.identifier
)
await ctx.debug(
"Performing ID-based chart lookup: chart_id=%s" % (chart_id,)
)
chart = ChartDAO.find_by_id(chart_id)
else:
await ctx.debug(
"Performing UUID-based chart lookup: uuid=%s"
% (request.identifier,)
)
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
if not chart:
await ctx.error("Chart not found: identifier=%s" % (request.identifier,))
@@ -232,8 +235,9 @@ async def get_chart_data( # noqa: C901
)
# Execute the query
command = ChartDataCommand(query_context)
result = command.run()
with event_logger.log_context(action="mcp.get_chart_data.query_execution"):
command = ChartDataCommand(query_context)
result = command.run()
# Handle empty query results for certain chart types
if not result or ("queries" not in result) or len(result["queries"]) == 0:
@@ -385,21 +389,27 @@ async def get_chart_data( # noqa: C901
# Handle different export formats
if request.format == "csv":
return _export_data_as_csv(
chart,
data[: request.limit] if request.limit else data,
raw_columns,
cache_status,
performance,
)
with event_logger.log_context(
action="mcp.get_chart_data.format_conversion"
):
return _export_data_as_csv(
chart,
data[: request.limit] if request.limit else data,
raw_columns,
cache_status,
performance,
)
elif request.format == "excel":
return _export_data_as_excel(
chart,
data[: request.limit] if request.limit else data,
raw_columns,
cache_status,
performance,
)
with event_logger.log_context(
action="mcp.get_chart_data.format_conversion"
):
return _export_data_as_excel(
chart,
data[: request.limit] if request.limit else data,
raw_columns,
cache_status,
performance,
)
await ctx.report_progress(4, 4, "Building response")

View File

@@ -24,6 +24,7 @@ import logging
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import (
ChartError,
ChartInfo,
@@ -71,16 +72,17 @@ async def get_chart_info(
"Retrieving chart information: identifier=%s" % (request.identifier,)
)
tool = ModelGetInfoCore(
dao_class=ChartDAO,
output_schema=ChartInfo,
error_schema=ChartError,
serializer=serialize_chart_object,
supports_slug=False, # Charts don't have slugs
logger=logger,
)
with event_logger.log_context(action="mcp.get_chart_info.lookup"):
tool = ModelGetInfoCore(
dao_class=ChartDAO,
output_schema=ChartInfo,
error_schema=ChartError,
serializer=serialize_chart_object,
supports_slug=False, # Charts don't have slugs
logger=logger,
)
result = tool.run_tool(request.identifier)
result = tool.run_tool(request.identifier)
if isinstance(result, ChartInfo):
await ctx.info(

View File

@@ -25,6 +25,7 @@ from typing import Any, Dict, List, Protocol
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import (
AccessibilityMetadata,
ASCIIPreview,
@@ -1807,65 +1808,71 @@ async def _get_chart_preview_internal( # noqa: C901
from superset.daos.chart import ChartDAO
# Find the chart
chart: Any = None
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
):
chart_id = (
int(request.identifier)
if isinstance(request.identifier, str)
else request.identifier
)
await ctx.debug(
"Performing ID-based chart lookup: chart_id=%s" % (chart_id,)
)
chart = ChartDAO.find_by_id(chart_id)
else:
await ctx.debug(
"Performing UUID-based chart lookup: uuid=%s" % (request.identifier,)
)
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
# If not found and looks like a form_data_key, try to create transient chart
if (
not chart
and isinstance(request.identifier, str)
and len(request.identifier) > 8
with event_logger.log_context(action="mcp.get_chart_preview.chart_lookup"):
chart: Any = None
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
):
# This might be a form_data_key, try to get form data from cache
from superset.commands.explore.form_data.get import GetFormDataCommand
from superset.commands.explore.form_data.parameters import (
CommandParameters,
chart_id = (
int(request.identifier)
if isinstance(request.identifier, str)
else request.identifier
)
await ctx.debug(
"Performing ID-based chart lookup: chart_id=%s" % (chart_id,)
)
chart = ChartDAO.find_by_id(chart_id)
else:
await ctx.debug(
"Performing UUID-based chart lookup: uuid=%s"
% (request.identifier,)
)
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
try:
cmd_params = CommandParameters(key=request.identifier)
cmd = GetFormDataCommand(cmd_params)
form_data_json = cmd.run()
if form_data_json:
from superset.utils import json as utils_json
form_data = utils_json.loads(form_data_json)
# Create a transient chart object from form data
class TransientChart:
def __init__(self, form_data: Dict[str, Any]):
self.id = None
self.slice_name = "Unsaved Chart Preview"
self.viz_type = form_data.get("viz_type", "table")
self.datasource_id = None
self.datasource_type = "table"
self.params = utils_json.dumps(form_data)
self.form_data = form_data
self.uuid = None
chart = TransientChart(form_data)
except Exception as e:
# Form data key not found or invalid
logger.debug(
"Failed to get form data for key %s: %s", request.identifier, e
# If not found and looks like a form_data_key, try transient
if (
not chart
and isinstance(request.identifier, str)
and len(request.identifier) > 8
):
# This might be a form_data_key
from superset.commands.explore.form_data.get import (
GetFormDataCommand,
)
from superset.commands.explore.form_data.parameters import (
CommandParameters,
)
try:
cmd_params = CommandParameters(key=request.identifier)
cmd = GetFormDataCommand(cmd_params)
form_data_json = cmd.run()
if form_data_json:
from superset.utils import json as utils_json
form_data = utils_json.loads(form_data_json)
# Create a transient chart object from form data
class TransientChart:
def __init__(self, form_data: Dict[str, Any]):
self.id = None
self.slice_name = "Unsaved Chart Preview"
self.viz_type = form_data.get("viz_type", "table")
self.datasource_id = None
self.datasource_type = "table"
self.params = utils_json.dumps(form_data)
self.form_data = form_data
self.uuid = None
chart = TransientChart(form_data)
except (ValueError, KeyError, AttributeError, TypeError) as e:
# Form data key not found or invalid
logger.debug(
"Failed to get form data for key %s: %s",
request.identifier,
e,
)
if not chart:
await ctx.error("Chart not found: identifier=%s" % (request.identifier,))
@@ -1911,8 +1918,11 @@ async def _get_chart_preview_internal( # noqa: C901
)
# Handle different preview formats using strategy pattern
preview_generator = PreviewFormatGenerator(chart, request)
content = preview_generator.generate()
with event_logger.log_context(
action="mcp.get_chart_preview.preview_generation"
):
preview_generator = PreviewFormatGenerator(chart, request)
content = preview_generator.generate()
if isinstance(content, ChartError):
await ctx.error(
@@ -1930,18 +1940,19 @@ async def _get_chart_preview_internal( # noqa: C901
await ctx.report_progress(3, 3, "Building response")
# Create performance and accessibility metadata
execution_time = int((time.time() - start_time) * 1000)
performance = PerformanceMetadata(
query_duration_ms=execution_time,
cache_status="miss",
optimization_suggestions=[],
)
with event_logger.log_context(action="mcp.get_chart_preview.metadata"):
execution_time = int((time.time() - start_time) * 1000)
performance = PerformanceMetadata(
query_duration_ms=execution_time,
cache_status="miss",
optimization_suggestions=[],
)
accessibility = AccessibilityMetadata(
color_blind_safe=True,
alt_text=f"Preview of {chart.slice_name or f'Chart {chart.id}'}",
high_contrast_available=False,
)
accessibility = AccessibilityMetadata(
color_blind_safe=True,
alt_text=f"Preview of {chart.slice_name or f'Chart {chart.id}'}",
high_contrast_available=False,
)
await ctx.debug(
"Preview generation completed: execution_time_ms=%s, content_type=%s"

View File

@@ -28,6 +28,7 @@ from superset_core.mcp import tool
if TYPE_CHECKING:
from superset.models.slice import Slice
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import (
ChartFilter,
ChartInfo,
@@ -121,15 +122,16 @@ async def list_charts(request: ListChartsRequest, ctx: Context) -> ChartList:
)
try:
result = tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
with event_logger.log_context(action="mcp.list_charts.query"):
result = tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
count = len(result.charts) if hasattr(result, "charts") else 0
total_pages = getattr(result, "total_pages", None)
await ctx.info(
@@ -145,9 +147,10 @@ async def list_charts(request: ListChartsRequest, ctx: Context) -> ChartList:
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
return result.model_dump(
mode="json", context={"select_columns": columns_to_filter}
)
with event_logger.log_context(action="mcp.list_charts.serialization"):
return result.model_dump(
mode="json", context={"select_columns": columns_to_filter}
)
except Exception as e:
await ctx.error("Failed to list charts: %s" % (str(e),))
raise

View File

@@ -25,6 +25,7 @@ import time
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.chart.chart_utils import (
analyze_chart_capabilities,
analyze_chart_semantics,
@@ -99,19 +100,20 @@ async def update_chart(
# Find the existing chart
from superset.daos.chart import ChartDAO
chart = None
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
):
chart_id = (
int(request.identifier)
if isinstance(request.identifier, str)
else request.identifier
)
chart = ChartDAO.find_by_id(chart_id)
else:
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
with event_logger.log_context(action="mcp.update_chart.chart_lookup"):
chart = None
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
):
chart_id = (
int(request.identifier)
if isinstance(request.identifier, str)
else request.identifier
)
chart = ChartDAO.find_by_id(chart_id)
else:
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
if not chart:
return GenerateChartResponse.model_validate(
@@ -132,21 +134,22 @@ async def update_chart(
# Update chart using Superset's command
from superset.commands.chart.update import UpdateChartCommand
# Generate new chart name if provided, otherwise keep existing
chart_name = (
request.chart_name
if request.chart_name
else chart.slice_name or generate_chart_name(request.config)
)
with event_logger.log_context(action="mcp.update_chart.db_write"):
# Generate new chart name if provided, otherwise keep existing
chart_name = (
request.chart_name
if request.chart_name
else chart.slice_name or generate_chart_name(request.config)
)
update_payload = {
"slice_name": chart_name,
"viz_type": new_form_data["viz_type"],
"params": json.dumps(new_form_data),
}
update_payload = {
"slice_name": chart_name,
"viz_type": new_form_data["viz_type"],
"params": json.dumps(new_form_data),
}
command = UpdateChartCommand(chart.id, update_payload)
updated_chart = command.run()
command = UpdateChartCommand(chart.id, update_payload)
updated_chart = command.run()
# Generate semantic analysis
capabilities = analyze_chart_capabilities(updated_chart, request.config)
@@ -176,21 +179,23 @@ async def update_chart(
previews = {}
if request.generate_preview:
try:
from superset.mcp_service.chart.tool.get_chart_preview import (
_get_chart_preview_internal,
GetChartPreviewRequest,
)
for format_type in request.preview_formats:
preview_request = GetChartPreviewRequest(
identifier=str(updated_chart.id), format=format_type
)
preview_result = await _get_chart_preview_internal(
preview_request, ctx
with event_logger.log_context(action="mcp.update_chart.preview"):
from superset.mcp_service.chart.tool.get_chart_preview import (
_get_chart_preview_internal,
GetChartPreviewRequest,
)
if hasattr(preview_result, "content"):
previews[format_type] = preview_result.content
for format_type in request.preview_formats:
preview_request = GetChartPreviewRequest(
identifier=str(updated_chart.id),
format=format_type,
)
preview_result = await _get_chart_preview_internal(
preview_request, ctx
)
if hasattr(preview_result, "content"):
previews[format_type] = preview_result.content
except Exception as e:
# Log warning but don't fail the entire request

View File

@@ -26,6 +26,7 @@ from typing import Any, Dict
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.chart.chart_utils import (
analyze_chart_capabilities,
analyze_chart_semantics,
@@ -65,23 +66,25 @@ def update_chart_preview(
start_time = time.time()
try:
# Map the new config to form_data format
# Pass dataset_id to enable column type checking for proper viz_type selection
new_form_data = map_config_to_form_data(
request.config, dataset_id=request.dataset_id
)
with event_logger.log_context(action="mcp.update_chart_preview.form_data"):
# Map the new config to form_data format
# Pass dataset_id to enable column type checking
new_form_data = map_config_to_form_data(
request.config, dataset_id=request.dataset_id
)
# Generate new explore link with updated form_data
explore_url = generate_explore_link(request.dataset_id, new_form_data)
# Generate new explore link with updated form_data
explore_url = generate_explore_link(request.dataset_id, new_form_data)
# Extract new form_data_key from the explore URL
new_form_data_key = None
if "form_data_key=" in explore_url:
new_form_data_key = explore_url.split("form_data_key=")[1].split("&")[0]
# Generate semantic analysis
capabilities = analyze_chart_capabilities(None, request.config)
semantics = analyze_chart_semantics(None, request.config)
with event_logger.log_context(action="mcp.update_chart_preview.metadata"):
# Generate semantic analysis
capabilities = analyze_chart_capabilities(None, request.config)
semantics = analyze_chart_semantics(None, request.config)
# Create performance metadata
execution_time = int((time.time() - start_time) * 1000)

View File

@@ -27,6 +27,7 @@ from typing import Any, Dict
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
AddChartToDashboardRequest,
AddChartToDashboardResponse,
@@ -147,75 +148,79 @@ def add_chart_to_existing_dashboard(
from superset.commands.dashboard.update import UpdateDashboardCommand
from superset.daos.dashboard import DashboardDAO
# Validate dashboard exists
dashboard = DashboardDAO.find_by_id(request.dashboard_id)
if not dashboard:
return AddChartToDashboardResponse(
dashboard=None,
dashboard_url=None,
position=None,
error=f"Dashboard with ID {request.dashboard_id} not found",
# Validate dashboard and chart exist
with event_logger.log_context(action="mcp.add_chart_to_dashboard.validation"):
dashboard = DashboardDAO.find_by_id(request.dashboard_id)
if not dashboard:
return AddChartToDashboardResponse(
dashboard=None,
dashboard_url=None,
position=None,
error=(f"Dashboard with ID {request.dashboard_id} not found"),
)
# Get chart object for SQLAlchemy relationships and validation
from superset import db
from superset.models.slice import Slice
new_chart = db.session.get(Slice, request.chart_id)
if not new_chart:
return AddChartToDashboardResponse(
dashboard=None,
dashboard_url=None,
position=None,
error=f"Chart with ID {request.chart_id} not found",
)
# Check if chart is already in dashboard
current_chart_ids = [slice.id for slice in dashboard.slices]
if request.chart_id in current_chart_ids:
return AddChartToDashboardResponse(
dashboard=None,
dashboard_url=None,
position=None,
error=(
f"Chart {request.chart_id} is already in dashboard "
f"{request.dashboard_id}"
),
)
# Calculate layout position
with event_logger.log_context(action="mcp.add_chart_to_dashboard.layout"):
# Parse current layout
try:
current_layout = json.loads(dashboard.position_json or "{}")
except (json.JSONDecodeError, TypeError):
current_layout = {}
# Find position for new chart
row_index = _find_next_row_position(current_layout)
# Add chart and row to layout
chart_key, row_key = _add_chart_to_layout(
current_layout, new_chart, request.chart_id, row_index
)
# Get chart object for SQLAlchemy relationships and validation
from superset import db
from superset.models.slice import Slice
new_chart = db.session.get(Slice, request.chart_id)
if not new_chart:
return AddChartToDashboardResponse(
dashboard=None,
dashboard_url=None,
position=None,
error=f"Chart with ID {request.chart_id} not found",
)
# Check if chart is already in dashboard
current_chart_ids = [slice.id for slice in dashboard.slices]
if request.chart_id in current_chart_ids:
return AddChartToDashboardResponse(
dashboard=None,
dashboard_url=None,
position=None,
error=(
f"Chart {request.chart_id} is already in dashboard "
f"{request.dashboard_id}"
),
)
# Parse current layout
try:
current_layout = json.loads(dashboard.position_json or "{}")
except (json.JSONDecodeError, TypeError):
current_layout = {}
# Find position for new chart
row_index = _find_next_row_position(current_layout)
# Add chart and row to layout
chart_key, row_key = _add_chart_to_layout(
current_layout, new_chart, request.chart_id, row_index
)
# Ensure proper layout structure
_ensure_layout_structure(current_layout, row_key)
# Get chart objects for SQLAlchemy relationships
# Get existing chart objects
existing_chart_objects = dashboard.slices
# Combine existing and new chart objects (new_chart was retrieved above)
all_chart_objects = list(existing_chart_objects) + [new_chart]
# Prepare update data
update_data = {
"position_json": json.dumps(current_layout),
"slices": all_chart_objects, # Pass ORM objects, not IDs
}
# Ensure proper layout structure
_ensure_layout_structure(current_layout, row_key)
# Update the dashboard
command = UpdateDashboardCommand(request.dashboard_id, update_data)
updated_dashboard = command.run()
with event_logger.log_context(action="mcp.add_chart_to_dashboard.db_write"):
# Get existing chart objects
existing_chart_objects = dashboard.slices
# Combine existing and new chart objects
all_chart_objects = list(existing_chart_objects) + [new_chart]
# Prepare update data
update_data = {
"position_json": json.dumps(current_layout),
"slices": all_chart_objects, # Pass ORM objects, not IDs
}
# Update the dashboard
command = UpdateDashboardCommand(request.dashboard_id, update_data)
updated_dashboard = command.run()
# Convert to response format
from superset.mcp_service.dashboard.schemas import (

View File

@@ -27,6 +27,7 @@ from typing import Any, Dict, List
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
DashboardInfo,
GenerateDashboardRequest,
@@ -137,57 +138,63 @@ def generate_dashboard(
from superset.commands.dashboard.create import CreateDashboardCommand
from superset.models.slice import Slice
chart_objects = (
db.session.query(Slice).filter(Slice.id.in_(request.chart_ids)).all()
)
found_chart_ids = [chart.id for chart in chart_objects]
# Check if all requested charts were found
missing_chart_ids = set(request.chart_ids) - set(found_chart_ids)
if missing_chart_ids:
return GenerateDashboardResponse(
dashboard=None,
dashboard_url=None,
error=f"Charts not found: {list(missing_chart_ids)}",
with event_logger.log_context(action="mcp.generate_dashboard.chart_validation"):
chart_objects = (
db.session.query(Slice).filter(Slice.id.in_(request.chart_ids)).all()
)
found_chart_ids = [chart.id for chart in chart_objects]
# Check if all requested charts were found
missing_chart_ids = set(request.chart_ids) - set(found_chart_ids)
if missing_chart_ids:
return GenerateDashboardResponse(
dashboard=None,
dashboard_url=None,
error=f"Charts not found: {list(missing_chart_ids)}",
)
# Create dashboard layout with chart objects
layout = _create_dashboard_layout(chart_objects)
with event_logger.log_context(action="mcp.generate_dashboard.layout"):
layout = _create_dashboard_layout(chart_objects)
# Prepare dashboard data
dashboard_data = {
"dashboard_title": request.dashboard_title,
"slug": None, # Let Superset auto-generate slug
"css": "",
"json_metadata": json.dumps(
{
"filter_scopes": {},
"expanded_slices": {},
"refresh_frequency": 0,
"timed_refresh_immune_slices": [],
"color_scheme": None,
"label_colors": {},
"shared_label_colors": {},
"color_scheme_domain": [],
"cross_filters_enabled": False,
"native_filter_configuration": [],
"global_chart_configuration": {
"scope": {"rootPath": ["ROOT_ID"], "excluded": []}
},
"chart_configuration": {},
}
),
"position_json": json.dumps(layout),
"published": request.published,
"slices": chart_objects, # Pass ORM objects, not IDs
}
# Prepare dashboard data and create dashboard
with event_logger.log_context(action="mcp.generate_dashboard.db_write"):
dashboard_data = {
"dashboard_title": request.dashboard_title,
"slug": None, # Let Superset auto-generate slug
"css": "",
"json_metadata": json.dumps(
{
"filter_scopes": {},
"expanded_slices": {},
"refresh_frequency": 0,
"timed_refresh_immune_slices": [],
"color_scheme": None,
"label_colors": {},
"shared_label_colors": {},
"color_scheme_domain": [],
"cross_filters_enabled": False,
"native_filter_configuration": [],
"global_chart_configuration": {
"scope": {
"rootPath": ["ROOT_ID"],
"excluded": [],
}
},
"chart_configuration": {},
}
),
"position_json": json.dumps(layout),
"published": request.published,
"slices": chart_objects, # Pass ORM objects, not IDs
}
if request.description:
dashboard_data["description"] = request.description
if request.description:
dashboard_data["description"] = request.description
# Create the dashboard using Superset's command pattern
command = CreateDashboardCommand(dashboard_data)
dashboard = command.run()
# Create the dashboard using Superset's command pattern
command = CreateDashboardCommand(dashboard_data)
dashboard = command.run()
# Convert to our response format
from superset.mcp_service.dashboard.schemas import (

View File

@@ -28,6 +28,7 @@ from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
dashboard_serializer,
DashboardError,
@@ -59,16 +60,17 @@ async def get_dashboard_info(
try:
from superset.daos.dashboard import DashboardDAO
tool = ModelGetInfoCore(
dao_class=DashboardDAO,
output_schema=DashboardInfo,
error_schema=DashboardError,
serializer=dashboard_serializer,
supports_slug=True, # Dashboards support slugs
logger=logger,
)
with event_logger.log_context(action="mcp.get_dashboard_info.lookup"):
tool = ModelGetInfoCore(
dao_class=DashboardDAO,
output_schema=DashboardInfo,
error_schema=DashboardError,
serializer=dashboard_serializer,
supports_slug=True, # Dashboards support slugs
logger=logger,
)
result = tool.run_tool(request.identifier)
result = tool.run_tool(request.identifier)
if isinstance(result, DashboardInfo):
await ctx.info(

View File

@@ -31,6 +31,7 @@ from superset_core.mcp import tool
if TYPE_CHECKING:
from superset.models.dashboard import Dashboard
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
DashboardFilter,
DashboardInfo,
@@ -123,15 +124,16 @@ async def list_dashboards(
logger=logger,
)
result = tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
with event_logger.log_context(action="mcp.list_dashboards.query"):
result = tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
count = len(result.dashboards) if hasattr(result, "dashboards") else 0
total_pages = getattr(result, "total_pages", None)
await ctx.info(
@@ -147,4 +149,7 @@ async def list_dashboards(
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
return result.model_dump(mode="json", context={"select_columns": columns_to_filter})
with event_logger.log_context(action="mcp.list_dashboards.serialization"):
return result.model_dump(
mode="json", context={"select_columns": columns_to_filter}
)

View File

@@ -54,6 +54,7 @@ class DatasetFilter(ColumnOperator):
col: Literal[
"table_name",
"schema",
"database_name",
"owner",
"favorite",
] = Field(

View File

@@ -28,6 +28,7 @@ from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.dataset.schemas import (
DatasetError,
DatasetInfo,
@@ -83,16 +84,17 @@ async def get_dataset_info(
try:
from superset.daos.dataset import DatasetDAO
tool = ModelGetInfoCore(
dao_class=DatasetDAO,
output_schema=DatasetInfo,
error_schema=DatasetError,
serializer=serialize_dataset_object,
supports_slug=False, # Datasets don't have slugs
logger=logger,
)
with event_logger.log_context(action="mcp.get_dataset_info.lookup"):
tool = ModelGetInfoCore(
dao_class=DatasetDAO,
output_schema=DatasetInfo,
error_schema=DatasetError,
serializer=serialize_dataset_object,
supports_slug=False, # Datasets don't have slugs
logger=logger,
)
result = tool.run_tool(request.identifier)
result = tool.run_tool(request.identifier)
if isinstance(result, DatasetInfo):
await ctx.info(

View File

@@ -31,6 +31,7 @@ from superset_core.mcp import tool
if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable
from superset.extensions import event_logger
from superset.mcp_service.dataset.schemas import (
DatasetFilter,
DatasetInfo,
@@ -129,15 +130,16 @@ async def list_datasets(request: ListDatasetsRequest, ctx: Context) -> DatasetLi
logger=logger,
)
result = tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
with event_logger.log_context(action="mcp.list_datasets.query"):
result = tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"Datasets listed successfully: count=%s, total_count=%s, total_pages=%s"
@@ -156,9 +158,11 @@ async def list_datasets(request: ListDatasetsRequest, ctx: Context) -> DatasetLi
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
return result.model_dump(
mode="json", context={"select_columns": columns_to_filter}
)
with event_logger.log_context(action="mcp.list_datasets.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(

View File

@@ -28,6 +28,7 @@ from urllib.parse import parse_qs, urlparse
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.chart.chart_utils import (
generate_explore_link as generate_url,
map_config_to_form_data,
@@ -91,11 +92,11 @@ async def generate_explore_link(
try:
await ctx.report_progress(1, 3, "Converting configuration to form data")
# Map config to form_data using shared utilities
# Pass dataset_id to enable column type checking for proper viz_type selection
form_data = map_config_to_form_data(
request.config, dataset_id=request.dataset_id
)
with event_logger.log_context(action="mcp.generate_explore_link.form_data"):
# Map config to form_data using shared utilities
form_data = map_config_to_form_data(
request.config, dataset_id=request.dataset_id
)
# Add datasource to form_data for consistency with generate_chart
# Only set if not already present to avoid overwriting
@@ -112,8 +113,13 @@ async def generate_explore_link(
)
await ctx.report_progress(2, 3, "Generating explore URL")
# Generate explore link using shared utilities
explore_url = generate_url(dataset_id=request.dataset_id, form_data=form_data)
with event_logger.log_context(
action="mcp.generate_explore_link.url_generation"
):
# Generate explore link using shared utilities
explore_url = generate_url(
dataset_id=request.dataset_id, form_data=form_data
)
# Extract form_data_key from the explore URL using proper URL parsing
form_data_key = None

View File

@@ -87,20 +87,47 @@ def _sanitize_error_for_logging(error: Exception) -> str:
return error_str
_SENSITIVE_PARAM_KEYS = frozenset(
{
"password",
"token",
"api_key",
"secret",
"credentials",
"authorization",
"cookie",
}
)
def _sanitize_params(params: dict[str, Any]) -> dict[str, Any]:
"""Remove sensitive fields from params before logging."""
if not isinstance(params, dict):
return params
return {
k: "[REDACTED]" if k.lower() in _SENSITIVE_PARAM_KEYS else v
for k, v in params.items()
}
class LoggingMiddleware(Middleware):
"""
Middleware that logs every MCP message (request and response) using the
event logger. This matches the core audit log system (Action Log UI,
logs table, custom loggers). Also attempts to log dashboard_id, chart_id
(slice_id), and dataset_id if present in tool params.
Tool calls are handled in on_call_tool() which wraps execution to capture
duration_ms. Non-tool messages (resource reads, prompts, etc.) are handled
in on_message().
"""
async def on_message(
self,
context: MiddlewareContext,
call_next: Callable[[MiddlewareContext], Awaitable[Any]],
) -> Any:
# Extract agent_id and user_id
def _extract_context_info(
self, context: MiddlewareContext
) -> tuple[
str | None, int | None, int | None, int | None, int | None, dict[str, Any]
]:
"""Extract agent_id, user_id, and entity IDs from context."""
agent_id = None
user_id = None
dashboard_id = None
@@ -113,18 +140,78 @@ class LoggingMiddleware(Middleware):
agent_id = getattr(context.session, "agent_id", None)
try:
user_id = get_user_id()
except Exception:
except (RuntimeError, AttributeError):
user_id = None
# Try to extract IDs from params
if isinstance(params, dict):
dashboard_id = params.get("dashboard_id")
# Chart ID may be under 'chart_id' or 'slice_id'
slice_id = params.get("chart_id") or params.get("slice_id")
dataset_id = params.get("dataset_id")
# Log to Superset's event logger (DB, Action Log UI, or custom)
return agent_id, user_id, dashboard_id, slice_id, dataset_id, params
async def on_call_tool(
self,
context: MiddlewareContext,
call_next: Callable[[MiddlewareContext], Awaitable[Any]],
) -> Any:
"""Log tool calls with duration tracking."""
agent_id, user_id, dashboard_id, slice_id, dataset_id, params = (
self._extract_context_info(context)
)
tool_name = getattr(context.message, "name", None)
start_time = time.time()
success = False
try:
result = await call_next(context)
success = True
return result
finally:
duration_ms = int((time.time() - start_time) * 1000)
event_logger.log(
user_id=user_id,
action="mcp_tool_call",
dashboard_id=dashboard_id,
duration_ms=duration_ms,
slice_id=slice_id,
referrer=None,
curated_payload={
"tool": tool_name,
"agent_id": agent_id,
"params": _sanitize_params(params),
"method": context.method,
"dashboard_id": dashboard_id,
"slice_id": slice_id,
"dataset_id": dataset_id,
"success": success,
},
)
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
"success=%s",
tool_name,
agent_id,
user_id,
context.method,
dashboard_id,
slice_id,
dataset_id,
duration_ms,
success,
)
async def on_message(
self,
context: MiddlewareContext,
call_next: Callable[[MiddlewareContext], Awaitable[Any]],
) -> Any:
"""Log non-tool messages (resource reads, prompts, etc.)."""
agent_id, user_id, dashboard_id, slice_id, dataset_id, params = (
self._extract_context_info(context)
)
event_logger.log(
user_id=user_id,
action="mcp_tool_call",
action="mcp_message",
dashboard_id=dashboard_id,
duration_ms=None,
slice_id=slice_id,
@@ -132,24 +219,19 @@ class LoggingMiddleware(Middleware):
curated_payload={
"tool": getattr(context.message, "name", None),
"agent_id": agent_id,
"params": params,
"params": _sanitize_params(params),
"method": context.method,
"dashboard_id": dashboard_id,
"slice_id": slice_id,
"dataset_id": dataset_id,
},
)
# (Optional) also log to standard logger for debugging
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s",
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
getattr(context.message, "name", None),
agent_id,
user_id,
context.method,
dashboard_id,
slice_id,
dataset_id,
)
return await call_next(context)

View File

@@ -33,6 +33,7 @@ from superset_core.mcp import tool
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorException, SupersetSecurityException
from superset.extensions import event_logger
from superset.mcp_service.sql_lab.schemas import (
ColumnInfo,
ExecuteSqlRequest,
@@ -72,28 +73,29 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
from superset.models.core import Database
# 1. Get database and check access
database = 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,
)
with event_logger.log_context(action="mcp.execute_sql.db_validation"):
database = (
db.session.query(Database).filter_by(id=request.database_id).first()
)
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,
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,
)
)
)
# 2. Build QueryOptions
# Caching is enabled by default to reduce database load.
# force_refresh bypasses cache when user explicitly requests fresh data.
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,
)
)
# 2. Build QueryOptions and execute query
cache_opts = CacheOptions(force_refresh=True) if request.force_refresh else None
options = QueryOptions(
catalog=request.catalog,
@@ -106,10 +108,12 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
)
# 3. Execute query
result = database.execute(request.sql, options)
with event_logger.log_context(action="mcp.execute_sql.query_execution"):
result = database.execute(request.sql, options)
# 4. Convert to MCP response format
response = _convert_to_response(result)
with event_logger.log_context(action="mcp.execute_sql.response_conversion"):
response = _convert_to_response(result)
# Log successful execution
if response.success:

View File

@@ -27,6 +27,7 @@ from urllib.parse import urlencode
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.sql_lab.schemas import (
OpenSqlLabRequest,
SqlLabResponse,
@@ -48,8 +49,9 @@ def open_sql_lab_with_context(
try:
from superset.daos.database import DatabaseDAO
# Validate database exists and is accessible
database = DatabaseDAO.find_by_id(request.database_connection_id)
with event_logger.log_context(action="mcp.open_sql_lab.db_validation"):
# Validate database exists and is accessible
database = DatabaseDAO.find_by_id(request.database_connection_id)
if not database:
return SqlLabResponse(
url="",

View File

@@ -25,6 +25,7 @@ import logging
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import InstanceInfoCore
from superset.mcp_service.system.schemas import (
GetSupersetInstanceInfoRequest,
@@ -98,7 +99,8 @@ def get_instance_info(
}
# Run the configurable core
return _instance_info_core.run_tool()
with event_logger.log_context(action="mcp.get_instance_info.metrics"):
return _instance_info_core.run_tool()
except Exception as e:
error_msg = f"Unexpected error in instance info: {str(e)}"

View File

@@ -29,6 +29,7 @@ from typing import Callable, Literal
from fastmcp import Context
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.common.schema_discovery import (
CHART_DEFAULT_COLUMNS,
CHART_SEARCH_COLUMNS,
@@ -154,8 +155,9 @@ async def get_schema(request: GetSchemaRequest, ctx: Context) -> GetSchemaRespon
)
# Create core instance and run (columns extracted dynamically)
core = factory()
schema_info = core.run_tool()
with event_logger.log_context(action="mcp.get_schema.discovery"):
core = factory()
schema_info = core.run_tool()
await ctx.debug(
f"Schema for {request.model_type}: "

View File

@@ -24,6 +24,7 @@ import platform
from flask import current_app
from superset_core.mcp import tool
from superset.extensions import event_logger
from superset.mcp_service.system.schemas import HealthCheckResponse
from superset.utils.version import get_version_metadata
@@ -64,9 +65,10 @@ async def health_check() -> HealthCheckResponse:
service_name = f"{app_name} MCP Service"
try:
# Get version from Superset version metadata
version_metadata = get_version_metadata()
version = version_metadata.get("version_string", "unknown")
with event_logger.log_context(action="mcp.health_check.status"):
# Get version from Superset version metadata
version_metadata = get_version_metadata()
version = version_metadata.get("version_string", "unknown")
response = HealthCheckResponse(
status="healthy",

View File

@@ -26,7 +26,7 @@ from flask_appbuilder.security.sqla.models import RegisterUser, Role
from flask_wtf.csrf import generate_csrf
from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError
from sqlalchemy import asc, desc
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from superset.commands.dashboard.embedded.exceptions import (
EmbeddedDashboardNotFoundError,
@@ -298,7 +298,9 @@ class RoleRestAPI(BaseSupersetApi):
page_size = args.get("page_size", 10)
query = db.session.query(Role).options(
joinedload(Role.permissions), joinedload(Role.user)
selectinload(Role.permissions),
selectinload(Role.user),
selectinload(Role.groups),
)
filters = args.get("filters", [])
@@ -318,6 +320,8 @@ class RoleRestAPI(BaseSupersetApi):
if "name" in filter_dict:
query = query.filter(Role.name.ilike(f"%{filter_dict['name']}%"))
total_count = query.count()
roles = (
query.order_by(order_by).offset(page * page_size).limit(page_size).all()
)
@@ -334,7 +338,7 @@ class RoleRestAPI(BaseSupersetApi):
}
for role in roles
],
count=query.count(),
count=total_count,
ids=[role.id for role in roles],
)
except ForbiddenError as e:

View File

@@ -20,8 +20,9 @@
import jwt
import pytest
from flask.ctx import AppContext
from flask_wtf.csrf import generate_csrf
from superset import db
from superset import db, security_manager
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.models.dashboard import Dashboard
from superset.utils.urls import get_url_host
@@ -35,6 +36,76 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
)
@pytest.fixture
def create_test_roles_with_users(app_context: AppContext):
"""
Fixture that creates two test roles with specific users, permissions, and groups.
"""
user1, user2, user3 = [
security_manager.add_user(
username=f"test_user_{i}",
first_name="Test",
last_name=f"User{i}",
email=f"test_user_{i}@test.com",
role=[],
password="password", # noqa: S106
)
for i in range(3)
]
test_group = security_manager.add_group(
name="test_group_1",
label="Test Group 1",
description="Test group for role testing",
roles=[],
)
pvm1 = security_manager.add_permission_view_menu("can_read", "Dashboard")
pvm2 = security_manager.add_permission_view_menu("can_write", "Dashboard")
pvm3 = security_manager.add_permission_view_menu("can_read", "Chart")
test_role_1 = security_manager.add_role("test_role_1", [pvm1, pvm2])
test_role_1.user.append(user1)
test_role_1.user.append(user2)
test_role_1.groups.append(test_group)
test_role_2 = security_manager.add_role("test_role_2", [pvm3])
test_role_2.user.append(user3)
db.session.commit()
test_data = {
"test_role_1": {
"role": test_role_1,
"user_ids": sorted([user1.id, user2.id]),
"permission_ids": sorted([pvm1.id, pvm2.id]),
"group_ids": [test_group.id],
},
"test_role_2": {
"role": test_role_2,
"user_ids": [user3.id],
"permission_ids": [pvm3.id],
"group_ids": [],
},
}
yield test_data
# Cleanup
db.session.delete(test_role_1)
db.session.delete(test_role_2)
db.session.delete(user1)
db.session.delete(user2)
db.session.delete(user3)
db.session.delete(test_group)
db.session.commit()
@pytest.fixture
def inject_test_roles_data(request, create_test_roles_with_users):
request.instance.test_roles_data = create_test_roles_with_users
class TestSecurityCsrfApi(SupersetTestCase):
resource_name = "security"
@@ -293,3 +364,41 @@ class TestSecurityRolesApi(SupersetTestCase):
self.login(GAMMA_USERNAME)
response = self.client.get(self.show_uri)
self.assert403(response)
@pytest.mark.usefixtures("inject_test_roles_data")
def test_get_roles_with_specific_test_data(self):
"""
Security API: Test roles endpoint with specific test data
"""
self.login(ADMIN_USERNAME)
response = self.client.get(f"{self.show_uri}?q=(page_size:100)")
self.assert200(response)
data = json.loads(response.data.decode("utf-8"))
# Create a mapping of role names to API response
api_roles_by_name = {role["name"]: role for role in data["result"]}
# Verify test_role_1
assert "test_role_1" in api_roles_by_name, (
f"test_role_1 not found in API response. "
f"Available roles: {list(api_roles_by_name.keys())}"
)
role1_api = api_roles_by_name["test_role_1"]
role1_expected = self.test_roles_data["test_role_1"]
assert sorted(role1_api["user_ids"]) == role1_expected["user_ids"]
assert sorted(role1_api["permission_ids"]) == role1_expected["permission_ids"]
assert sorted(role1_api["group_ids"]) == role1_expected["group_ids"]
# Verify test_role_2
assert "test_role_2" in api_roles_by_name, (
f"test_role_2 not found in API response. "
f"Available roles: {list(api_roles_by_name.keys())}"
)
role2_api = api_roles_by_name["test_role_2"]
role2_expected = self.test_roles_data["test_role_2"]
assert sorted(role2_api["user_ids"]) == role2_expected["user_ids"]
assert sorted(role2_api["permission_ids"]) == role2_expected["permission_ids"]
assert role2_api["group_ids"] == role2_expected["group_ids"]

View File

@@ -14,11 +14,12 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for the examples importer, specifically SQL transpilation."""
"""Tests for the examples importer: orchestration, transpilation, normalization."""
from unittest.mock import MagicMock, patch
from superset.commands.importers.v1.examples import transpile_virtual_dataset_sql
from superset.examples.utils import _normalize_dataset_schema
def test_transpile_virtual_dataset_sql_no_sql():
@@ -242,3 +243,133 @@ def test_transpile_virtual_dataset_sql_postgres_to_sqlite(mock_transpile, mock_d
assert config["sql"] == transpiled_sql
mock_transpile.assert_called_once_with(original_sql, "sqlite", "postgresql")
@patch(
"superset.commands.importers.v1.examples.safe_insert_dashboard_chart_relationships"
)
@patch("superset.commands.importers.v1.examples.import_dashboard")
@patch("superset.commands.importers.v1.examples.import_chart")
@patch("superset.commands.importers.v1.examples.import_dataset")
@patch("superset.commands.importers.v1.examples.import_database")
def test_import_passes_ignore_permissions_to_all_importers(
mock_import_db,
mock_import_dataset,
mock_import_chart,
mock_import_dashboard,
mock_safe_insert,
):
"""_import() must pass ignore_permissions=True to all importers.
This is the key wiring test: the security bypass for system imports
only works if _import() passes ignore_permissions=True to each
sub-importer. Without this, SQLite example databases are blocked
by PREVENT_UNSAFE_DB_CONNECTIONS.
"""
from superset.commands.importers.v1.examples import ImportExamplesCommand
db_uuid = "a2dc77af-e654-49bb-b321-40f6b559a1ee"
dataset_uuid = "14f48794-ebfa-4f60-a26a-582c49132f1b"
chart_uuid = "cccccccc-cccc-cccc-cccc-cccccccccccc"
dashboard_uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd"
# Mock database import
mock_db_obj = MagicMock()
mock_db_obj.uuid = db_uuid
mock_db_obj.id = 1
mock_import_db.return_value = mock_db_obj
# Mock dataset import
mock_dataset_obj = MagicMock()
mock_dataset_obj.uuid = dataset_uuid
mock_dataset_obj.id = 10
mock_dataset_obj.table_name = "test_table"
mock_import_dataset.return_value = mock_dataset_obj
# Mock chart import
mock_chart_obj = MagicMock()
mock_chart_obj.uuid = chart_uuid
mock_chart_obj.id = 100
mock_import_chart.return_value = mock_chart_obj
# Mock dashboard import
mock_dashboard_obj = MagicMock()
mock_dashboard_obj.id = 1000
mock_import_dashboard.return_value = mock_dashboard_obj
configs = {
"databases/examples.yaml": {
"uuid": db_uuid,
"database_name": "examples",
"sqlalchemy_uri": "sqlite:///test.db",
},
"datasets/examples/test.yaml": {
"uuid": dataset_uuid,
"table_name": "test_table",
"database_uuid": db_uuid,
"schema": None,
"sql": None,
},
"charts/test/chart.yaml": {
"uuid": chart_uuid,
"dataset_uuid": dataset_uuid,
},
"dashboards/test.yaml": {
"uuid": dashboard_uuid,
"position": {},
},
}
with patch(
"superset.commands.importers.v1.examples.get_example_default_schema",
return_value=None,
):
with patch(
"superset.commands.importers.v1.examples.find_chart_uuids",
return_value=[],
):
with patch(
"superset.commands.importers.v1.examples.update_id_refs",
return_value=configs["dashboards/test.yaml"],
):
ImportExamplesCommand._import(configs)
# Verify ALL importers received ignore_permissions=True
mock_import_db.assert_called_once()
assert mock_import_db.call_args[1].get("ignore_permissions") is True
mock_import_dataset.assert_called_once()
assert mock_import_dataset.call_args[1].get("ignore_permissions") is True
mock_import_chart.assert_called_once()
assert mock_import_chart.call_args[1].get("ignore_permissions") is True
mock_import_dashboard.assert_called_once()
assert mock_import_dashboard.call_args[1].get("ignore_permissions") is True
def test_normalize_dataset_schema_converts_main_to_null():
"""SQLite 'main' schema must be normalized to null in YAML content.
This normalization happens in the YAML import path (utils.py), which is
separate from the data_loading.py normalization. Both paths must handle
SQLite's default 'main' schema correctly.
"""
content = "table_name: test\nschema: main\nuuid: abc-123"
result = _normalize_dataset_schema(content)
assert "schema: null" in result
assert "schema: main" not in result
def test_normalize_dataset_schema_preserves_other_schemas():
"""Non-'main' schemas should be left unchanged."""
content = "table_name: test\nschema: public\nuuid: abc-123"
result = _normalize_dataset_schema(content)
assert "schema: public" in result
def test_normalize_dataset_schema_preserves_null_schema():
"""Already-null schemas should remain null."""
content = "table_name: test\nschema: null\nuuid: abc-123"
result = _normalize_dataset_schema(content)
assert "schema: null" in result

View File

@@ -120,6 +120,39 @@ def test_import_database_sqlite_invalid(
current_app.config["PREVENT_UNSAFE_DB_CONNECTIONS"] = True
def test_import_database_sqlite_allowed_with_ignore_permissions(
mocker: MockerFixture, session: Session
) -> None:
"""
Test that SQLite imports succeed when ignore_permissions=True.
System imports (like examples) use URIs from server config, not user input,
so they should bypass the PREVENT_UNSAFE_DB_CONNECTIONS check. This is the
key fix from PR #37577 that allows example loading to work in CI/showtime
environments where PREVENT_UNSAFE_DB_CONNECTIONS is enabled.
"""
from superset.commands.database.importers.v1.utils import import_database
from superset.models.core import Database
from tests.integration_tests.fixtures.importexport import database_config_sqlite
mocker.patch.dict(current_app.config, {"PREVENT_UNSAFE_DB_CONNECTIONS": True})
mocker.patch("superset.commands.database.importers.v1.utils.add_permissions")
engine = db.session.get_bind()
Database.metadata.create_all(engine) # pylint: disable=no-member
config = copy.deepcopy(database_config_sqlite)
# With ignore_permissions=True, the security check should be skipped
database = import_database(config, ignore_permissions=True)
assert database.database_name == "imported_database"
assert "sqlite" in database.sqlalchemy_uri
# Cleanup
db.session.delete(database)
db.session.flush()
def test_import_database_managed_externally(
mocker: MockerFixture,
session: Session,

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,204 @@
# 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 data_loading.py UUID extraction functionality."""
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
import yaml
def test_get_dataset_config_from_yaml_extracts_uuid():
"""Test that UUID is extracted from dataset.yaml."""
from superset.examples.data_loading import get_dataset_config_from_yaml
with TemporaryDirectory() as tmpdir:
example_dir = Path(tmpdir)
dataset_yaml = example_dir / "dataset.yaml"
dataset_yaml.write_text(
yaml.dump(
{
"table_name": "test_table",
"uuid": "12345678-1234-1234-1234-123456789012",
"schema": "public",
}
)
)
config = get_dataset_config_from_yaml(example_dir)
assert config["uuid"] == "12345678-1234-1234-1234-123456789012"
assert config["table_name"] == "test_table"
assert config["schema"] == "public"
def test_get_dataset_config_from_yaml_without_uuid():
"""Test that missing UUID returns None."""
from superset.examples.data_loading import get_dataset_config_from_yaml
with TemporaryDirectory() as tmpdir:
example_dir = Path(tmpdir)
dataset_yaml = example_dir / "dataset.yaml"
dataset_yaml.write_text(
yaml.dump(
{
"table_name": "test_table",
"schema": "public",
}
)
)
config = get_dataset_config_from_yaml(example_dir)
assert config["uuid"] is None
assert config["table_name"] == "test_table"
def test_get_dataset_config_from_yaml_no_file():
"""Test behavior when dataset.yaml doesn't exist."""
from superset.examples.data_loading import get_dataset_config_from_yaml
with TemporaryDirectory() as tmpdir:
example_dir = Path(tmpdir)
config = get_dataset_config_from_yaml(example_dir)
assert config["uuid"] is None
assert config["table_name"] is None
assert config["schema"] is None
def test_get_dataset_config_from_yaml_treats_main_schema_as_none():
"""Test that SQLite's 'main' schema is treated as None."""
from superset.examples.data_loading import get_dataset_config_from_yaml
with TemporaryDirectory() as tmpdir:
example_dir = Path(tmpdir)
dataset_yaml = example_dir / "dataset.yaml"
dataset_yaml.write_text(
yaml.dump(
{
"table_name": "test_table",
"schema": "main", # SQLite default schema
}
)
)
config = get_dataset_config_from_yaml(example_dir)
assert config["schema"] is None
def test_get_multi_dataset_config_extracts_uuid():
"""Test that UUID is extracted from datasets/{name}.yaml."""
from superset.examples.data_loading import _get_multi_dataset_config
with TemporaryDirectory() as tmpdir:
example_dir = Path(tmpdir)
datasets_dir = example_dir / "datasets"
datasets_dir.mkdir()
dataset_yaml = datasets_dir / "test_dataset.yaml"
dataset_yaml.write_text(
yaml.dump(
{
"table_name": "custom_table_name",
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"schema": "public",
}
)
)
data_file = example_dir / "data" / "test_dataset.parquet"
config = _get_multi_dataset_config(example_dir, "test_dataset", data_file)
assert config["uuid"] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assert config["table_name"] == "custom_table_name"
def test_get_multi_dataset_config_without_yaml():
"""Test behavior when datasets/{name}.yaml doesn't exist."""
from superset.examples.data_loading import _get_multi_dataset_config
with TemporaryDirectory() as tmpdir:
example_dir = Path(tmpdir)
data_file = example_dir / "data" / "test_dataset.parquet"
config = _get_multi_dataset_config(example_dir, "test_dataset", data_file)
assert config.get("uuid") is None
assert config["table_name"] == "test_dataset"
def test_get_multi_dataset_config_treats_main_schema_as_none():
"""Test that SQLite's 'main' schema is treated as None in multi-dataset config."""
from superset.examples.data_loading import _get_multi_dataset_config
with TemporaryDirectory() as tmpdir:
example_dir = Path(tmpdir)
datasets_dir = example_dir / "datasets"
datasets_dir.mkdir()
dataset_yaml = datasets_dir / "test_dataset.yaml"
dataset_yaml.write_text(
yaml.dump(
{
"table_name": "test_table",
"schema": "main",
}
)
)
data_file = example_dir / "data" / "test_dataset.parquet"
config = _get_multi_dataset_config(example_dir, "test_dataset", data_file)
assert config["schema"] is None
def test_discover_datasets_passes_uuid_to_loader():
"""Test that discover_datasets passes UUID from YAML to create_generic_loader."""
from superset.examples.data_loading import discover_datasets
with TemporaryDirectory() as tmpdir:
examples_dir = Path(tmpdir)
# Create a simple example with data.parquet and dataset.yaml
example_dir = examples_dir / "test_example"
example_dir.mkdir()
(example_dir / "data.parquet").touch()
(example_dir / "dataset.yaml").write_text(
yaml.dump(
{
"table_name": "test_table",
"uuid": "12345678-1234-1234-1234-123456789012",
}
)
)
with patch(
"superset.examples.data_loading.get_examples_directory",
return_value=examples_dir,
):
with patch(
"superset.examples.data_loading.create_generic_loader"
) as mock_create:
mock_create.return_value = lambda: None
discover_datasets()
mock_create.assert_called_once()
call_kwargs = mock_create.call_args[1]
assert call_kwargs["uuid"] == "12345678-1234-1234-1234-123456789012"

View File

@@ -0,0 +1,233 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for generic_loader.py UUID threading functionality."""
from unittest.mock import MagicMock, patch
@patch("superset.examples.generic_loader.get_example_database")
@patch("superset.examples.generic_loader.db")
def test_load_parquet_table_sets_uuid_on_new_table(mock_db, mock_get_db):
"""Test that load_parquet_table sets UUID on newly created SqlaTable."""
from superset.examples.generic_loader import load_parquet_table
mock_database = MagicMock()
mock_database.id = 1
mock_database.has_table.return_value = True
mock_get_db.return_value = mock_database
mock_engine = MagicMock()
mock_inspector = MagicMock()
mock_inspector.default_schema_name = "public"
mock_database.get_sqla_engine.return_value.__enter__ = MagicMock(
return_value=mock_engine
)
mock_database.get_sqla_engine.return_value.__exit__ = MagicMock(return_value=False)
# Simulate table not found in metadata
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
test_uuid = "12345678-1234-1234-1234-123456789012"
with patch("superset.examples.generic_loader.inspect") as mock_inspect:
mock_inspect.return_value = mock_inspector
tbl = load_parquet_table(
parquet_file="test_data",
table_name="test_table",
database=mock_database,
only_metadata=True,
uuid=test_uuid,
)
assert tbl.uuid == test_uuid
@patch("superset.examples.generic_loader.get_example_database")
@patch("superset.examples.generic_loader.db")
def test_load_parquet_table_early_return_does_not_modify_existing_uuid(
mock_db, mock_get_db
):
"""Test early return path when table exists - UUID is not modified.
When the physical table exists and force=False, the function returns early
without going through the full load path. The existing table's UUID is
preserved as-is (not modified even if different from the provided uuid).
"""
from superset.examples.generic_loader import load_parquet_table
mock_database = MagicMock()
mock_database.id = 1
mock_database.has_table.return_value = True # Triggers early return
mock_get_db.return_value = mock_database
mock_engine = MagicMock()
mock_inspector = MagicMock()
mock_inspector.default_schema_name = "public"
mock_database.get_sqla_engine.return_value.__enter__ = MagicMock(
return_value=mock_engine
)
mock_database.get_sqla_engine.return_value.__exit__ = MagicMock(return_value=False)
# Simulate existing table without UUID
existing_table = MagicMock()
existing_table.uuid = None
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing_table
)
test_uuid = "12345678-1234-1234-1234-123456789012"
with patch("superset.examples.generic_loader.inspect") as mock_inspect:
mock_inspect.return_value = mock_inspector
tbl = load_parquet_table(
parquet_file="test_data",
table_name="test_table",
database=mock_database,
only_metadata=True,
uuid=test_uuid,
)
# Early return path returns existing table as-is
assert tbl is existing_table
# UUID was not modified (still None)
assert tbl.uuid is None
@patch("superset.examples.generic_loader.get_example_database")
@patch("superset.examples.generic_loader.db")
def test_load_parquet_table_preserves_existing_uuid(mock_db, mock_get_db):
"""Test that load_parquet_table does not overwrite existing UUID."""
from superset.examples.generic_loader import load_parquet_table
mock_database = MagicMock()
mock_database.id = 1
mock_database.has_table.return_value = True
mock_get_db.return_value = mock_database
mock_engine = MagicMock()
mock_inspector = MagicMock()
mock_inspector.default_schema_name = "public"
mock_database.get_sqla_engine.return_value.__enter__ = MagicMock(
return_value=mock_engine
)
mock_database.get_sqla_engine.return_value.__exit__ = MagicMock(return_value=False)
# Simulate existing table with different UUID
existing_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
existing_table = MagicMock()
existing_table.uuid = existing_uuid
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing_table
)
new_uuid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
with patch("superset.examples.generic_loader.inspect") as mock_inspect:
mock_inspect.return_value = mock_inspector
tbl = load_parquet_table(
parquet_file="test_data",
table_name="test_table",
database=mock_database,
only_metadata=True,
uuid=new_uuid,
)
# Should preserve original UUID
assert tbl.uuid == existing_uuid
@patch("superset.examples.generic_loader.get_example_database")
@patch("superset.examples.generic_loader.db")
def test_load_parquet_table_works_without_uuid(mock_db, mock_get_db):
"""Test that load_parquet_table works correctly when no UUID is provided."""
from superset.examples.generic_loader import load_parquet_table
mock_database = MagicMock()
mock_database.id = 1
mock_database.has_table.return_value = True
mock_get_db.return_value = mock_database
mock_engine = MagicMock()
mock_inspector = MagicMock()
mock_inspector.default_schema_name = "public"
mock_database.get_sqla_engine.return_value.__enter__ = MagicMock(
return_value=mock_engine
)
mock_database.get_sqla_engine.return_value.__exit__ = MagicMock(return_value=False)
# Simulate table not found
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
with patch("superset.examples.generic_loader.inspect") as mock_inspect:
mock_inspect.return_value = mock_inspector
tbl = load_parquet_table(
parquet_file="test_data",
table_name="test_table",
database=mock_database,
only_metadata=True,
# No uuid parameter
)
# UUID should remain None
assert tbl.uuid is None
def test_create_generic_loader_passes_uuid():
"""Test that create_generic_loader passes UUID to load_parquet_table."""
from superset.examples.generic_loader import create_generic_loader
test_uuid = "12345678-1234-1234-1234-123456789012"
loader = create_generic_loader(
parquet_file="test_data",
table_name="test_table",
uuid=test_uuid,
)
# Verify loader was created with UUID in closure
with patch("superset.examples.generic_loader.load_parquet_table") as mock_load:
mock_load.return_value = MagicMock()
loader(only_metadata=True)
# Verify UUID was passed through
mock_load.assert_called_once()
call_kwargs = mock_load.call_args[1]
assert call_kwargs["uuid"] == test_uuid
def test_create_generic_loader_without_uuid():
"""Test that create_generic_loader works without UUID (backward compat)."""
from superset.examples.generic_loader import create_generic_loader
loader = create_generic_loader(
parquet_file="test_data",
table_name="test_table",
# No uuid
)
with patch("superset.examples.generic_loader.load_parquet_table") as mock_load:
mock_load.return_value = MagicMock()
loader(only_metadata=True)
mock_load.assert_called_once()
call_kwargs = mock_load.call_args[1]
assert call_kwargs["uuid"] is None

View File

@@ -0,0 +1,206 @@
# 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 examples/utils.py - YAML config loading and content assembly."""
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import MagicMock, patch
import yaml
def _create_example_tree(base_dir: Path) -> Path:
"""Create a minimal example directory tree under base_dir/superset/examples/.
Returns the 'superset' directory (what files("superset") would return).
"""
superset_dir = base_dir / "superset"
examples_dir = superset_dir / "examples"
# _shared configs
shared_dir = examples_dir / "_shared"
shared_dir.mkdir(parents=True)
(shared_dir / "database.yaml").write_text(
"database_name: examples\n"
"sqlalchemy_uri: __SQLALCHEMY_EXAMPLES_URI__\n"
"uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee\n"
"version: '1.0.0'\n"
)
(shared_dir / "metadata.yaml").write_text(
"version: '1.0.0'\ntimestamp: '2020-12-11T22:52:56.534241+00:00'\n"
)
# An example with dataset, dashboard, and chart
example_dir = examples_dir / "test_example"
example_dir.mkdir()
(example_dir / "dataset.yaml").write_text(
yaml.dump(
{
"table_name": "test_table",
"schema": "main",
"uuid": "14f48794-ebfa-4f60-a26a-582c49132f1b",
"database_uuid": "a2dc77af-e654-49bb-b321-40f6b559a1ee",
"version": "1.0.0",
}
)
)
(example_dir / "dashboard.yaml").write_text(
yaml.dump(
{
"dashboard_title": "Test Dashboard",
"uuid": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"version": "1.0.0",
}
)
)
charts_dir = example_dir / "charts"
charts_dir.mkdir()
(charts_dir / "test_chart.yaml").write_text(
yaml.dump(
{
"slice_name": "Test Chart",
"uuid": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"dataset_uuid": "14f48794-ebfa-4f60-a26a-582c49132f1b",
"version": "1.0.0",
}
)
)
return superset_dir
def test_load_contents_builds_correct_import_structure():
"""load_contents() must produce the key structure ImportExamplesCommand expects.
This tests the orchestration entry point: YAML files are discovered from
the examples directory, the shared database config has its URI placeholder
replaced, and the result has the correct key prefixes (databases/, datasets/,
metadata.yaml).
"""
from superset.examples.utils import load_contents
with TemporaryDirectory() as tmpdir:
superset_dir = _create_example_tree(Path(tmpdir))
test_examples_uri = "sqlite:///path/to/examples.db"
mock_app = MagicMock()
mock_app.config = {"SQLALCHEMY_EXAMPLES_URI": test_examples_uri}
with patch("superset.examples.utils.files", return_value=superset_dir):
with patch("flask.current_app", mock_app):
contents = load_contents()
# Verify database config is present with placeholder replaced
assert "databases/examples.yaml" in contents
db_content = contents["databases/examples.yaml"]
assert "__SQLALCHEMY_EXAMPLES_URI__" not in db_content
assert test_examples_uri in db_content
# Verify metadata is present
assert "metadata.yaml" in contents
# Verify dataset is discovered with correct key prefix
assert "datasets/examples/test_example.yaml" in contents
# Verify dashboard is discovered with correct key prefix
assert "dashboards/test_example.yaml" in contents
# Verify chart is discovered with correct key prefix
assert "charts/test_example/test_chart.yaml" in contents
# Verify schema normalization happened (main -> null)
dataset_content = contents["datasets/examples/test_example.yaml"]
assert "schema: main" not in dataset_content
assert "schema: null" in dataset_content
def test_load_contents_replaces_sqlalchemy_examples_uri_placeholder():
"""The __SQLALCHEMY_EXAMPLES_URI__ placeholder must be replaced with the real URI.
If this placeholder is not replaced, the database import will fail with an
invalid connection string, preventing all examples from loading.
"""
from superset.examples.utils import _load_shared_configs
with TemporaryDirectory() as tmpdir:
superset_dir = _create_example_tree(Path(tmpdir))
examples_root = Path("examples")
test_uri = "postgresql://user:pass@host/db"
mock_app = MagicMock()
mock_app.config = {"SQLALCHEMY_EXAMPLES_URI": test_uri}
with patch("superset.examples.utils.files", return_value=superset_dir):
with patch("flask.current_app", mock_app):
contents = _load_shared_configs(examples_root)
assert "databases/examples.yaml" in contents
assert test_uri in contents["databases/examples.yaml"]
assert "__SQLALCHEMY_EXAMPLES_URI__" not in contents["databases/examples.yaml"]
@patch("superset.examples.utils.ImportExamplesCommand")
@patch("superset.examples.utils.load_contents")
def test_load_examples_from_configs_wires_command_correctly(
mock_load_contents,
mock_command_cls,
):
"""load_examples_from_configs() must construct ImportExamplesCommand
with overwrite=True and thread force_data through.
A wiring regression here would silently skip overwriting existing
examples or ignore the force_data flag.
"""
from superset.examples.utils import load_examples_from_configs
mock_load_contents.return_value = {"databases/examples.yaml": "content"}
mock_command = MagicMock()
mock_command_cls.return_value = mock_command
load_examples_from_configs(force_data=True)
mock_load_contents.assert_called_once_with(False)
mock_command_cls.assert_called_once_with(
{"databases/examples.yaml": "content"},
overwrite=True,
force_data=True,
)
mock_command.run.assert_called_once()
@patch("superset.examples.utils.ImportExamplesCommand")
@patch("superset.examples.utils.load_contents")
def test_load_examples_from_configs_defaults(
mock_load_contents,
mock_command_cls,
):
"""Default call should pass force_data=False and load_test_data=False."""
from superset.examples.utils import load_examples_from_configs
mock_load_contents.return_value = {}
mock_command = MagicMock()
mock_command_cls.return_value = mock_command
load_examples_from_configs()
mock_load_contents.assert_called_once_with(False)
mock_command_cls.assert_called_once_with(
{},
overwrite=True,
force_data=False,
)
mock_command.run.assert_called_once()

View File

@@ -67,10 +67,13 @@ def test_extension_config_full():
"views": {
"sqllab": {
"panels": [
{"id": "query_insights.main", "name": "Query Insights"}
],
},
},
{
"id": "query_insights.main",
"name": "Query Insights",
}
]
}
}
},
"moduleFederation": {"exposes": ["./index"]},
},

View File

@@ -934,6 +934,52 @@ async def test_invalid_filter_column_raises(mcp_server):
)
def test_database_name_filter_accepted():
"""Test that database_name is accepted as a valid filter column.
Regression test for TypeError 'encoding without a string argument' when
filtering datasets by database_name.
"""
request = ListDatasetsRequest(
filters=[{"col": "database_name", "opr": "ilike", "value": "%dynamo%"}],
select_columns=["id", "database_name", "table_name"],
)
assert len(request.filters) == 1
assert request.filters[0].col == "database_name"
assert request.filters[0].opr.value == "ilike"
assert request.filters[0].value == "%dynamo%"
@patch("superset.daos.dataset.DatasetDAO.list")
@pytest.mark.asyncio
async def test_list_datasets_with_database_name_filter(mock_list, mcp_server):
"""Test list_datasets with database_name filter via MCP client.
Regression test: previously database_name was not in the allowed filter
columns, causing a Pydantic ValidationError that downstream code could
not serialize properly (TypeError: encoding without a string argument).
"""
dataset = create_mock_dataset(
dataset_id=5,
table_name="dynamo_table",
database_name="dynamodb",
)
mock_list.return_value = ([dataset], 1)
async with Client(mcp_server) as client:
request = ListDatasetsRequest(
filters=[{"col": "database_name", "opr": "ilike", "value": "%dynamo%"}],
select_columns=["id", "database_name", "table_name"],
)
result = await client.call_tool(
"list_datasets", {"request": request.model_dump()}
)
assert result.content is not None
data = json.loads(result.content[0].text)
assert data["datasets"] is not None
assert len(data["datasets"]) == 1
assert data["datasets"][0]["database_name"] == "dynamodb"
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dataset_info_includes_columns_and_metrics(mock_info, mcp_server):

View File

@@ -0,0 +1,207 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Unit tests for LoggingMiddleware on_call_tool() and on_message() methods.
Tests verify that:
- on_call_tool() captures duration_ms and success status
- on_message() logs non-tool messages without duration
- _extract_context_info() extracts entity IDs from params
"""
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from superset.mcp_service.middleware import LoggingMiddleware
def _make_context(
method: str = "tools/call",
name: str = "list_charts",
params: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
):
"""Create a mock MiddlewareContext."""
ctx = MagicMock()
ctx.method = method
message = MagicMock()
message.name = name
message.params = params or {}
ctx.message = message
if metadata is not None:
ctx.metadata = metadata
else:
ctx.metadata = None
ctx.session = None
return ctx
class TestLoggingMiddlewareOnCallTool:
"""Tests for LoggingMiddleware.on_call_tool()."""
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_logs_duration_and_success(
self, mock_get_user_id, mock_event_logger
):
"""on_call_tool records duration_ms and success=True on normal return."""
middleware = LoggingMiddleware()
ctx = _make_context(name="list_charts")
call_next = AsyncMock(return_value="tool_result")
result = await middleware.on_call_tool(ctx, call_next)
assert result == "tool_result"
call_next.assert_awaited_once_with(ctx)
# Verify event_logger.log was called with duration_ms and success
mock_event_logger.log.assert_called_once()
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["action"] == "mcp_tool_call"
assert call_kwargs["user_id"] == 42
assert isinstance(call_kwargs["duration_ms"], int)
assert call_kwargs["duration_ms"] >= 0
assert call_kwargs["curated_payload"]["success"] is True
assert call_kwargs["curated_payload"]["tool"] == "list_charts"
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_logs_failure_on_exception(
self, mock_get_user_id, mock_event_logger
):
"""on_call_tool records success=False when tool raises."""
middleware = LoggingMiddleware()
ctx = _make_context(name="execute_sql")
call_next = AsyncMock(side_effect=ValueError("boom"))
with pytest.raises(ValueError, match="boom"):
await middleware.on_call_tool(ctx, call_next)
# Verify event_logger.log was still called (in the finally block)
mock_event_logger.log.assert_called_once()
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["curated_payload"]["success"] is False
assert call_kwargs["duration_ms"] >= 0
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_extracts_entity_ids(
self, mock_get_user_id, mock_event_logger
):
"""on_call_tool extracts dashboard_id, chart_id, dataset_id from params."""
middleware = LoggingMiddleware()
ctx = _make_context(
name="get_chart_info",
params={
"dashboard_id": 10,
"chart_id": 20,
"dataset_id": 30,
},
)
call_next = AsyncMock(return_value="ok")
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["dashboard_id"] == 10
assert call_kwargs["slice_id"] == 20
assert call_kwargs["curated_payload"]["dataset_id"] == 30
class TestLoggingMiddlewareOnMessage:
"""Tests for LoggingMiddleware.on_message()."""
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=1)
@pytest.mark.asyncio
async def test_on_message_logs_without_duration(
self, mock_get_user_id, mock_event_logger
):
"""on_message logs with action=mcp_message and duration_ms=None."""
middleware = LoggingMiddleware()
ctx = _make_context(method="resources/read", name="instance/metadata")
call_next = AsyncMock(return_value="resource_data")
result = await middleware.on_message(ctx, call_next)
assert result == "resource_data"
call_next.assert_awaited_once_with(ctx)
mock_event_logger.log.assert_called_once()
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["action"] == "mcp_message"
assert call_kwargs["duration_ms"] is None
# on_message should NOT have success field
assert "success" not in call_kwargs["curated_payload"]
class TestExtractContextInfo:
"""Tests for LoggingMiddleware._extract_context_info()."""
@patch("superset.mcp_service.middleware.get_user_id", return_value=99)
def test_extract_with_metadata_agent_id(self, mock_get_user_id):
"""Extracts agent_id from context.metadata."""
middleware = LoggingMiddleware()
ctx = _make_context(metadata={"agent_id": "agent-123"})
agent_id, user_id, dashboard_id, slice_id, dataset_id, params = (
middleware._extract_context_info(ctx)
)
assert agent_id == "agent-123"
assert user_id == 99
@patch(
"superset.mcp_service.middleware.get_user_id",
side_effect=RuntimeError("no Flask request context"),
)
def test_extract_handles_missing_user(self, mock_get_user_id):
"""Gracefully handles missing user context."""
middleware = LoggingMiddleware()
ctx = _make_context()
agent_id, user_id, dashboard_id, slice_id, dataset_id, params = (
middleware._extract_context_info(ctx)
)
assert user_id is None
@patch("superset.mcp_service.middleware.get_user_id", return_value=1)
def test_extract_slice_id_from_chart_id(self, mock_get_user_id):
"""Extracts slice_id from chart_id param (alias)."""
middleware = LoggingMiddleware()
ctx = _make_context(params={"chart_id": 55})
_, _, _, slice_id, _, _ = middleware._extract_context_info(ctx)
assert slice_id == 55
@patch("superset.mcp_service.middleware.get_user_id", return_value=1)
def test_extract_slice_id_from_slice_id(self, mock_get_user_id):
"""Extracts slice_id from slice_id param (fallback)."""
middleware = LoggingMiddleware()
ctx = _make_context(params={"slice_id": 66})
_, _, _, slice_id, _, _ = middleware._extract_context_info(ctx)
assert slice_id == 66