mirror of
https://github.com/apache/superset.git
synced 2026-06-22 16:09:20 +00:00
Compare commits
12 Commits
upgrade-sq
...
feat/add-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e3172da5 | ||
|
|
142b2cc425 | ||
|
|
6328e51620 | ||
|
|
0d5ddb3674 | ||
|
|
58d245c6b0 | ||
|
|
dbf5e1f131 | ||
|
|
88ce1425e2 | ||
|
|
4dfece9ee5 | ||
|
|
3f64c25712 | ||
|
|
afacca350f | ||
|
|
30ccbb2e05 | ||
|
|
19ec7b48a0 |
36
UPDATING.md
36
UPDATING.md
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
701
superset-frontend/package-lock.json
generated
701
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,4 +17,5 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { default as isBlank } from './isBlank';
|
||||
export { default as logging } from './logging';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)))
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
superset-frontend/playwright/helpers/api/chart.ts
Normal file
104
superset-frontend/playwright/helpers/api/chart.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
132
superset-frontend/playwright/pages/ChartListPage.ts
Normal file
132
superset-frontend/playwright/pages/ChartListPage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -531,6 +531,7 @@ const ResultSet = ({
|
||||
placement="left"
|
||||
>
|
||||
<Label
|
||||
monospace
|
||||
css={css`
|
||||
line-height: ${theme.fontSizeLG}px;
|
||||
`}
|
||||
|
||||
BIN
superset-frontend/src/assets/images/doltdb.png
Normal file
BIN
superset-frontend/src/assets/images/doltdb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
1
superset-frontend/src/assets/images/postgis.svg
Normal file
1
superset-frontend/src/assets/images/postgis.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 93 KiB |
BIN
superset-frontend/src/assets/images/questdb.png
Normal file
BIN
superset-frontend/src/assets/images/questdb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
34
superset-frontend/src/assets/images/tidb.svg
Normal file
34
superset-frontend/src/assets/images/tidb.svg
Normal 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 |
16
superset-frontend/src/assets/images/timeplus.svg
Normal file
16
superset-frontend/src/assets/images/timeplus.svg
Normal 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 |
@@ -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...')
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
133
superset-frontend/src/theme/hooks/useThemeValidation.test.ts
Normal file
133
superset-frontend/src/theme/hooks/useThemeValidation.test.ts
Normal 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);
|
||||
});
|
||||
155
superset-frontend/src/theme/hooks/useThemeValidation.ts
Normal file
155
superset-frontend/src/theme/hooks/useThemeValidation.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
108
superset-frontend/src/theme/utils/antdTokenNames.test.ts
Normal file
108
superset-frontend/src/theme/utils/antdTokenNames.test.ts
Normal 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');
|
||||
});
|
||||
115
superset-frontend/src/theme/utils/antdTokenNames.ts
Normal file
115
superset-frontend/src/theme/utils/antdTokenNames.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
191
superset-frontend/src/theme/utils/themeStructureValidation.ts
Normal file
191
superset-frontend/src/theme/utils/themeStructureValidation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
49
superset/db_engine_specs/doltdb.py
Normal file
49
superset/db_engine_specs/doltdb.py
Normal 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."
|
||||
),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
53
superset/db_engine_specs/postgis.py
Normal file
53
superset/db_engine_specs/postgis.py
Normal 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."
|
||||
),
|
||||
}
|
||||
62
superset/db_engine_specs/questdb.py
Normal file
62
superset/db_engine_specs/questdb.py
Normal 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})",
|
||||
}
|
||||
74
superset/db_engine_specs/tidb.py
Normal file
74
superset/db_engine_specs/tidb.py
Normal 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."
|
||||
),
|
||||
}
|
||||
63
superset/db_engine_specs/timeplus.py
Normal file
63
superset/db_engine_specs/timeplus.py
Normal 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})",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ class DatasetFilter(ColumnOperator):
|
||||
col: Literal[
|
||||
"table_name",
|
||||
"schema",
|
||||
"database_name",
|
||||
"owner",
|
||||
"favorite",
|
||||
] = Field(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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="",
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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}: "
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
tests/unit_tests/examples/__init__.py
Normal file
16
tests/unit_tests/examples/__init__.py
Normal 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.
|
||||
204
tests/unit_tests/examples/data_loading_test.py
Normal file
204
tests/unit_tests/examples/data_loading_test.py
Normal 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"
|
||||
233
tests/unit_tests/examples/generic_loader_test.py
Normal file
233
tests/unit_tests/examples/generic_loader_test.py
Normal 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
|
||||
206
tests/unit_tests/examples/utils_test.py
Normal file
206
tests/unit_tests/examples/utils_test.py
Normal 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()
|
||||
@@ -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"]},
|
||||
},
|
||||
|
||||
@@ -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):
|
||||
|
||||
207
tests/unit_tests/mcp_service/test_middleware_logging.py
Normal file
207
tests/unit_tests/mcp_service/test_middleware_logging.py
Normal 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
|
||||
Reference in New Issue
Block a user