mirror of
https://github.com/apache/superset.git
synced 2026-08-03 20:42:30 +00:00
Compare commits
12 Commits
fix/mcp-ex
...
msyavuz/fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39e3f10be | ||
|
|
69fbbfd7ce | ||
|
|
d3784879c2 | ||
|
|
ad5e3170dd | ||
|
|
aa710672ed | ||
|
|
8c80caefa3 | ||
|
|
8088c5d1de | ||
|
|
9b520312a1 | ||
|
|
9ac4711ac8 | ||
|
|
7593d2a164 | ||
|
|
d3c44e311e | ||
|
|
b5186d1c65 |
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Login to Amazon ECR
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Delete ECR image tag
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
|
||||
4
.github/workflows/ephemeral-env.yml
vendored
4
.github/workflows/ephemeral-env.yml
vendored
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Load, tag and push image to ECR
|
||||
id: push-image
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Check target image exists in ECR
|
||||
id: check-image
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.3.7",
|
||||
"baseline-browser-mapping": "^2.10.27",
|
||||
"caniuse-lite": "^1.0.30001791",
|
||||
"caniuse-lite": "^1.0.30001792",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
@@ -6035,10 +6035,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001791:
|
||||
version "1.0.30001791"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz#dfb93d85c40ad380c57123e72e10f3c575786b51"
|
||||
integrity sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001792:
|
||||
version "1.0.30001792"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5"
|
||||
integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
|
||||
@@ -142,10 +142,16 @@ druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.12, <0.3.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
|
||||
excel = ["xlrd>=1.2.0, <1.3"]
|
||||
fastmcp = ["fastmcp>=3.2.4,<4.0"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.2.4,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
"tiktoken>=0.7.0,<1.0",
|
||||
]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=23.9.1"]
|
||||
|
||||
@@ -183,7 +183,9 @@ idna==3.10
|
||||
# trio
|
||||
# url-normalize
|
||||
isodate==0.7.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
itsdangerous==2.2.0
|
||||
# via
|
||||
# flask
|
||||
@@ -296,6 +298,7 @@ pyarrow==20.0.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
pyasn1==0.6.3
|
||||
# via
|
||||
# pyasn1-modules
|
||||
|
||||
@@ -442,6 +442,7 @@ isodate==0.7.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
isort==6.0.1
|
||||
# via pylint
|
||||
itsdangerous==2.2.0
|
||||
@@ -715,6 +716,7 @@ pyarrow==20.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
pyasn1==0.6.3
|
||||
@@ -866,6 +868,8 @@ referencing==0.36.2
|
||||
# jsonschema
|
||||
# jsonschema-path
|
||||
# jsonschema-specifications
|
||||
regex==2026.4.4
|
||||
# via tiktoken
|
||||
requests==2.33.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -878,6 +882,7 @@ requests==2.33.0
|
||||
# requests-cache
|
||||
# requests-oauthlib
|
||||
# shillelagh
|
||||
# tiktoken
|
||||
# trino
|
||||
requests-cache==1.2.1
|
||||
# via
|
||||
@@ -1003,6 +1008,8 @@ tabulate==0.9.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
tiktoken==0.12.0
|
||||
# via apache-superset
|
||||
tomli-w==1.2.0
|
||||
# via apache-superset-extensions-cli
|
||||
tomlkit==0.13.3
|
||||
|
||||
@@ -66,7 +66,7 @@ export type EmbedDashboardParams = {
|
||||
iframeTitle?: string;
|
||||
/** additional iframe sandbox attributes ex (allow-top-navigation, allow-popups-to-escape-sandbox) **/
|
||||
iframeSandboxExtras?: string[];
|
||||
/** iframe allow attribute for Permissions Policy (e.g., ['clipboard-write', 'fullscreen']) **/
|
||||
/** Additional Permissions Policy features for the iframe's `allow` attribute (e.g., ['camera', 'microphone']). `fullscreen` and `clipboard-write` are granted by default. **/
|
||||
iframeAllowExtras?: string[];
|
||||
/** force a specific refererPolicy to be used in the iframe request **/
|
||||
referrerPolicy?: ReferrerPolicy;
|
||||
@@ -233,9 +233,14 @@ export async function embedDashboard({
|
||||
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
|
||||
iframe.title = iframeTitle;
|
||||
iframe.style.background = 'transparent';
|
||||
if (iframeAllowExtras.length > 0) {
|
||||
iframe.setAttribute('allow', iframeAllowExtras.join('; '));
|
||||
}
|
||||
// Permissions Policy features the embedded dashboard relies on. Modern
|
||||
// browsers gate these APIs on the iframe's `allow` attribute regardless
|
||||
// of sandbox flags, so we include them by default. Host apps can extend
|
||||
// the list via `iframeAllowExtras`.
|
||||
const allowFeatures = Array.from(
|
||||
new Set(['fullscreen', 'clipboard-write', ...iframeAllowExtras]),
|
||||
);
|
||||
iframe.setAttribute('allow', allowFeatures.join('; '));
|
||||
//@ts-ignore
|
||||
mountPoint.replaceChildren(iframe);
|
||||
log('placed the iframe');
|
||||
|
||||
497
superset-frontend/package-lock.json
generated
497
superset-frontend/package-lock.json
generated
@@ -66,6 +66,7 @@
|
||||
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
|
||||
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
|
||||
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
|
||||
"@superset-ui/plugin-chart-sandpack": "file:./plugins/plugin-chart-sandpack",
|
||||
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
|
||||
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
@@ -2713,6 +2714,206 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
|
||||
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
||||
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.42.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz",
|
||||
"integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codesandbox/nodebox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@codesandbox/nodebox/-/nodebox-0.1.8.tgz",
|
||||
"integrity": "sha512-2VRS6JDSk+M+pg56GA6CryyUSGPjBEe8Pnae0QL3jJF1mJZJVMDKr93gJRtBbLkfZN6LD/DwMtf+2L0bpWrjqg==",
|
||||
"license": "SEE LICENSE IN ./LICENSE",
|
||||
"dependencies": {
|
||||
"outvariant": "^1.4.0",
|
||||
"strict-event-emitter": "^0.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@codesandbox/sandpack-client": {
|
||||
"version": "2.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@codesandbox/sandpack-client/-/sandpack-client-2.19.8.tgz",
|
||||
"integrity": "sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codesandbox/nodebox": "0.1.8",
|
||||
"buffer": "^6.0.3",
|
||||
"dequal": "^2.0.2",
|
||||
"mime-db": "^1.52.0",
|
||||
"outvariant": "1.4.0",
|
||||
"static-browser-server": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@codesandbox/sandpack-client/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@codesandbox/sandpack-react": {
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@codesandbox/sandpack-react/-/sandpack-react-2.20.0.tgz",
|
||||
"integrity": "sha512-takd1YpW/PMQ6KPQfvseWLHWklJovGY8QYj8MtWnskGKbjOGJ6uZfyZbcJ6aCFLQMpNyjTqz9AKNbvhCOZ1TUQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.4.0",
|
||||
"@codemirror/commands": "^6.1.3",
|
||||
"@codemirror/lang-css": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.0",
|
||||
"@codemirror/lang-javascript": "^6.1.2",
|
||||
"@codemirror/language": "^6.3.2",
|
||||
"@codemirror/state": "^6.2.0",
|
||||
"@codemirror/view": "^6.7.1",
|
||||
"@codesandbox/sandpack-client": "^2.19.8",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@react-hook/intersection-observer": "^3.1.1",
|
||||
"@stitches/core": "^1.2.6",
|
||||
"anser": "^2.1.1",
|
||||
"clean-set": "^1.1.2",
|
||||
"dequal": "^2.0.2",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"lz-string": "^1.4.4",
|
||||
"react-devtools-inline": "4.4.0",
|
||||
"react-is": "^17.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@codesandbox/sandpack-react/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"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@@ -6850,6 +7051,63 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
||||
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@loaders.gl/3d-tiles": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz",
|
||||
@@ -7444,6 +7702,12 @@
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@math.gl/core": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
|
||||
@@ -8642,6 +8906,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@open-draft/deferred-promise": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
|
||||
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.62.0.tgz",
|
||||
@@ -9450,6 +9720,28 @@
|
||||
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-hook/intersection-observer": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-hook/intersection-observer/-/intersection-observer-3.1.2.tgz",
|
||||
"integrity": "sha512-mWU3BMkmmzyYMSuhO9wu3eJVP21N8TcgYm9bZnTrMwuM818bEk+0NRM3hP+c/TqA9Ln5C7qE53p1H0QMtzYdvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-hook/passive-layout-effect": "^1.2.0",
|
||||
"intersection-observer": "^0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-hook/passive-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-spring/animated": {
|
||||
"version": "9.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
|
||||
@@ -10032,6 +10324,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stitches/core": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@stitches/core/-/core-1.2.8.tgz",
|
||||
"integrity": "sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-actions": {
|
||||
"version": "8.6.18",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.18.tgz",
|
||||
@@ -12197,6 +12495,10 @@
|
||||
"resolved": "plugins/plugin-chart-point-cluster-map",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@superset-ui/plugin-chart-sandpack": {
|
||||
"resolved": "plugins/plugin-chart-sandpack",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@superset-ui/plugin-chart-table": {
|
||||
"resolved": "plugins/plugin-chart-table",
|
||||
"link": true
|
||||
@@ -16393,7 +16695,6 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz",
|
||||
"integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
@@ -18470,6 +18771,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-set": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clean-set/-/clean-set-1.1.2.tgz",
|
||||
"integrity": "sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
@@ -20114,6 +20421,12 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
@@ -20650,6 +20963,19 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/d": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
|
||||
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es5-ext": "^0.10.64",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "3.5.17",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz",
|
||||
@@ -21717,7 +22043,6 @@
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -22294,6 +22619,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es5-ext": {
|
||||
"version": "0.10.64",
|
||||
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
|
||||
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es6-iterator": "^2.0.3",
|
||||
"es6-symbol": "^3.1.3",
|
||||
"esniff": "^2.0.1",
|
||||
"next-tick": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-error": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
@@ -22301,6 +22642,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es6-iterator": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "^0.10.35",
|
||||
"es6-symbol": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-symbol": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
|
||||
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.2",
|
||||
"ext": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -22416,6 +22781,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-carriage": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz",
|
||||
"integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -23242,6 +23613,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esniff": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
|
||||
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.1",
|
||||
"es5-ext": "^0.10.62",
|
||||
"event-emitter": "^0.3.5",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
@@ -23363,6 +23749,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-emitter": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
@@ -23545,6 +23941,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ext": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
|
||||
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"type": "^2.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -27102,6 +27507,13 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/intersection-observer": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.10.0.tgz",
|
||||
"integrity": "sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==",
|
||||
"deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.",
|
||||
"license": "W3C-20150513"
|
||||
},
|
||||
"node_modules/interweave": {
|
||||
"version": "13.1.1",
|
||||
"resolved": "https://registry.npmjs.org/interweave/-/interweave-13.1.1.tgz",
|
||||
@@ -36272,7 +36684,6 @@
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -36759,6 +37170,12 @@
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-tick": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ngeohash": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
|
||||
@@ -38148,6 +38565,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/outvariant": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz",
|
||||
"integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -41145,6 +41568,15 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-devtools-inline": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-devtools-inline/-/react-devtools-inline-4.4.0.tgz",
|
||||
"integrity": "sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es6-symbol": "^3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-diff-viewer-continued": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.2.tgz",
|
||||
@@ -44424,6 +44856,18 @@
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/static-browser-server": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/static-browser-server/-/static-browser-server-1.0.3.tgz",
|
||||
"integrity": "sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@open-draft/deferred-promise": "^2.1.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"mime-db": "^1.52.0",
|
||||
"outvariant": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/static-eval": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz",
|
||||
@@ -44711,6 +45155,12 @@
|
||||
"bare-events": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-event-emitter": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz",
|
||||
"integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -45000,6 +45450,12 @@
|
||||
"webpack": "^5.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-object": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz",
|
||||
@@ -46615,6 +47071,12 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -47844,6 +48306,12 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
@@ -50796,6 +51264,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-sandpack": {
|
||||
"name": "@superset-ui/plugin-chart-sandpack",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codesandbox/sandpack-react": "^2.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"jest": "^30.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-table": {
|
||||
"name": "@superset-ui/plugin-chart-table",
|
||||
"version": "0.20.3",
|
||||
@@ -50893,7 +51382,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "^9.3.2",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
|
||||
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
|
||||
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
|
||||
"@superset-ui/plugin-chart-sandpack": "file:./plugins/plugin-chart-sandpack",
|
||||
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
|
||||
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
|
||||
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
|
||||
|
||||
@@ -50,6 +50,7 @@ export enum VizType {
|
||||
PivotTable = 'pivot_table_v2',
|
||||
Radar = 'radar',
|
||||
Rose = 'rose',
|
||||
Sandpack = 'sandpack',
|
||||
Sankey = 'sankey_v2',
|
||||
Scatter = 'echarts_timeseries_scatter',
|
||||
SmoothLine = 'echarts_timeseries_smooth',
|
||||
|
||||
@@ -494,6 +494,12 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'column_config',
|
||||
@@ -587,18 +593,12 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars'),
|
||||
label: t('Show cell bars for all columns'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -612,7 +612,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'align_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Align +/-'),
|
||||
label: t('Align +/- for all columns'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t(
|
||||
@@ -626,7 +626,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
label: t('Add colors to cell bars for +/- for all columns'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
|
||||
46
superset-frontend/plugins/plugin-chart-sandpack/package.json
Normal file
46
superset-frontend/plugins/plugin-chart-sandpack/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@superset-ui/plugin-chart-sandpack",
|
||||
"version": "0.1.0",
|
||||
"description": "Superset Chart - Render arbitrary Sandpack-powered apps against query data",
|
||||
"sideEffects": false,
|
||||
"main": "lib/index.js",
|
||||
"module": "esm/index.js",
|
||||
"files": [
|
||||
"esm",
|
||||
"lib"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apache/superset.git",
|
||||
"directory": "superset-frontend/plugins/plugin-chart-sandpack"
|
||||
},
|
||||
"keywords": [
|
||||
"superset",
|
||||
"sandpack"
|
||||
],
|
||||
"author": "Superset",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apache/superset/issues"
|
||||
},
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-sandpack#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codesandbox/sandpack-react": "^2.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"jest": "^30.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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 { styled } from '@apache-superset/core/theme';
|
||||
import { useMemo } from 'react';
|
||||
import { SandpackViewer } from './components/Sandpack/SandpackViewer';
|
||||
import { DEFAULT_APP_CODE, DEFAULT_DEPENDENCIES } from './consts';
|
||||
import { SandpackProps, SandpackStylesProps } from './types';
|
||||
|
||||
const Styles = styled.div<SandpackStylesProps>`
|
||||
height: ${({ height }) => height}px;
|
||||
width: ${({ width }) => width}px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
function safeParseDependencies(raw?: string): Record<string, string> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export default function SandpackChart(props: SandpackProps) {
|
||||
const { data, height, width, formData } = props;
|
||||
const appCode = formData.appCode || DEFAULT_APP_CODE;
|
||||
const template = formData.template || 'react';
|
||||
const layout = formData.layout || 'split';
|
||||
const showNavigator = formData.showNavigator ?? false;
|
||||
|
||||
const dependencies = useMemo(
|
||||
() => safeParseDependencies(formData.dependencies || DEFAULT_DEPENDENCIES),
|
||||
[formData.dependencies],
|
||||
);
|
||||
|
||||
return (
|
||||
<Styles height={height} width={width}>
|
||||
<SandpackViewer
|
||||
appCode={appCode}
|
||||
data={data}
|
||||
dependencies={dependencies}
|
||||
template={template}
|
||||
layout={layout}
|
||||
showNavigator={showNavigator}
|
||||
height={height}
|
||||
/>
|
||||
</Styles>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
CodeEditor,
|
||||
type CodeEditorProps,
|
||||
type CodeEditorMode,
|
||||
type CodeEditorTheme,
|
||||
} from '@superset-ui/core/components';
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { ReactNode } from 'react';
|
||||
|
||||
interface ControlHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ControlHeader = ({
|
||||
children,
|
||||
}: ControlHeaderProps): JSX.Element => (
|
||||
<div className="ControlHeader">
|
||||
<div className="pull-left">{children}</div>
|
||||
</div>
|
||||
);
|
||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { debounce } from 'lodash';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
|
||||
export const debounceFunc = debounce(
|
||||
(func: (val: string) => void, source: string) => func(source),
|
||||
Constants.SLOW_DEBOUNCE,
|
||||
);
|
||||
|
||||
export const DEFAULT_APP_CODE = `import data from './data.json';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui', padding: 16 }}>
|
||||
<h2>Rows: {data.length}</h2>
|
||||
<ul>
|
||||
{data.slice(0, 20).map((row, i) => (
|
||||
<li key={i}>{JSON.stringify(row)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
export const DEFAULT_DEPENDENCIES = `{
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
`;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
20
superset-frontend/plugins/plugin-chart-sandpack/src/index.ts
Normal file
20
superset-frontend/plugins/plugin-chart-sandpack/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as SandpackChartPlugin } from './plugin';
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 {
|
||||
buildQueryContext,
|
||||
normalizeOrderBy,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
export default function buildQuery(formData: QueryFormData) {
|
||||
return buildQueryContext(formData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
orderby: normalizeOrderBy(baseQueryObject).orderby,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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 {
|
||||
ControlPanelConfig,
|
||||
getStandardizedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { appCodeControlSetItem } from './controls/appCode';
|
||||
import { dependenciesControlSetItem } from './controls/dependencies';
|
||||
import {
|
||||
layoutControlSetItem,
|
||||
showNavigatorControlSetItem,
|
||||
templateControlSetItem,
|
||||
} from './controls/options';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: t('Query'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
['groupby'],
|
||||
['metrics'],
|
||||
['adhoc_filters'],
|
||||
['row_limit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('App'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[templateControlSetItem],
|
||||
[appCodeControlSetItem],
|
||||
[dependenciesControlSetItem],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Display'),
|
||||
expanded: false,
|
||||
controlSetRows: [[layoutControlSetItem], [showNavigatorControlSetItem]],
|
||||
},
|
||||
],
|
||||
formDataOverrides: formData => ({
|
||||
...formData,
|
||||
groupby: getStandardizedControls().popAllColumns(),
|
||||
metrics: getStandardizedControls().popAllMetrics(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 {
|
||||
ControlSetItem,
|
||||
CustomControlConfig,
|
||||
sharedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { InfoTooltip } from '@superset-ui/core/components';
|
||||
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
|
||||
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
|
||||
import { debounceFunc, DEFAULT_APP_CODE } from '../../consts';
|
||||
|
||||
interface AppCodeControlProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const AppCodeControl = (props: CustomControlConfig<AppCodeControlProps>) => {
|
||||
const theme = useTheme();
|
||||
const val = String(
|
||||
props?.value ? props?.value : props?.default ? props?.default : '',
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ControlHeader>
|
||||
<div>
|
||||
{typeof props.label === 'function' ? null : props.label}
|
||||
<InfoTooltip
|
||||
iconStyle={{ marginLeft: theme.sizeUnit }}
|
||||
tooltip={t(
|
||||
'Query results are exposed to the app as `./data.json`. ' +
|
||||
'Import it from your entry file to render the data.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ControlHeader>
|
||||
<CodeEditor
|
||||
theme="dark"
|
||||
mode="javascript"
|
||||
value={val}
|
||||
onChange={source => {
|
||||
debounceFunc(props.onChange, source || '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const appCodeControlSetItem: ControlSetItem = {
|
||||
name: 'appCode',
|
||||
config: {
|
||||
...sharedControls.entity,
|
||||
type: AppCodeControl,
|
||||
label: t('App Code'),
|
||||
description: t(
|
||||
'Source code for the Sandpack app entry file. The query result is ' +
|
||||
'available as `./data.json`.',
|
||||
),
|
||||
default: DEFAULT_APP_CODE,
|
||||
isInt: false,
|
||||
renderTrigger: true,
|
||||
valueKey: null,
|
||||
validators: [validateNonEmpty],
|
||||
mapStateToProps: ({ form_data }) => ({
|
||||
value: form_data?.appCode ?? form_data?.app_code,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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 {
|
||||
ControlSetItem,
|
||||
CustomControlConfig,
|
||||
sharedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
|
||||
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
|
||||
import { debounceFunc, DEFAULT_DEPENDENCIES } from '../../consts';
|
||||
|
||||
interface DependenciesControlProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const DependenciesControl = (
|
||||
props: CustomControlConfig<DependenciesControlProps>,
|
||||
) => (
|
||||
<div>
|
||||
<ControlHeader>
|
||||
<div>{typeof props.label === 'function' ? null : props.label}</div>
|
||||
</ControlHeader>
|
||||
<CodeEditor
|
||||
theme="dark"
|
||||
mode="json"
|
||||
value={props.value || ''}
|
||||
onChange={source => {
|
||||
debounceFunc(props.onChange, source || '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const dependenciesControlSetItem: ControlSetItem = {
|
||||
name: 'dependencies',
|
||||
config: {
|
||||
...sharedControls.entity,
|
||||
type: DependenciesControl,
|
||||
label: t('NPM Dependencies'),
|
||||
description: t(
|
||||
'JSON object of npm packages and versions made available to the app.',
|
||||
),
|
||||
default: DEFAULT_DEPENDENCIES,
|
||||
isInt: false,
|
||||
renderTrigger: true,
|
||||
valueKey: null,
|
||||
validators: [],
|
||||
mapStateToProps: ({ form_data }) => ({
|
||||
value: form_data?.dependencies,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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 { ControlSetItem } from '@superset-ui/chart-controls';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
export const templateControlSetItem: ControlSetItem = {
|
||||
name: 'template',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Sandpack Template'),
|
||||
description: t('Sandpack runtime template used to bundle the app.'),
|
||||
default: 'react',
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
choices: [
|
||||
['react', t('React (JS)')],
|
||||
['react-ts', t('React (TypeScript)')],
|
||||
['vanilla', t('Vanilla JS')],
|
||||
['vanilla-ts', t('Vanilla TypeScript')],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const layoutControlSetItem: ControlSetItem = {
|
||||
name: 'layout',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Layout'),
|
||||
description: t('Which Sandpack panes to show inside the chart frame.'),
|
||||
default: 'preview',
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
choices: [
|
||||
['preview', t('Preview only')],
|
||||
['split', t('Editor + preview')],
|
||||
['editor', t('Editor only')],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const showNavigatorControlSetItem: ControlSetItem = {
|
||||
name: 'showNavigator',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show navigator'),
|
||||
description: t('Display the Sandpack URL/navigator bar above the preview.'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import thumbnail from '../images/thumbnail.png';
|
||||
import thumbnailDark from '../images/thumbnail-dark.png';
|
||||
import buildQuery from './buildQuery';
|
||||
import controlPanel from './controlPanel';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
export default class SandpackChartPlugin extends ChartPlugin {
|
||||
constructor() {
|
||||
const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
'Render a Sandpack-powered React or JS app against the query result. ' +
|
||||
'The dataset is exposed to the app as `./data.json`.',
|
||||
),
|
||||
name: t('Sandpack App'),
|
||||
tags: [t('Advanced'), t('Custom'), t('Experimental')],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
behaviors: [Behavior.InteractiveChart],
|
||||
});
|
||||
|
||||
super({
|
||||
buildQuery,
|
||||
controlPanel,
|
||||
loadChart: () => import('../SandpackChart'),
|
||||
metadata,
|
||||
transformProps,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { ChartProps, TimeseriesDataRecord } from '@superset-ui/core';
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, queriesData } = chartProps;
|
||||
const data = queriesData[0].data as TimeseriesDataRecord[];
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
formData,
|
||||
};
|
||||
}
|
||||
54
superset-frontend/plugins/plugin-chart-sandpack/src/types.ts
Normal file
54
superset-frontend/plugins/plugin-chart-sandpack/src/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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 {
|
||||
QueryFormData,
|
||||
QueryFormMetric,
|
||||
TimeseriesDataRecord,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
export type SandpackTemplate = 'react' | 'react-ts' | 'vanilla' | 'vanilla-ts';
|
||||
|
||||
export type SandpackLayout = 'preview' | 'split' | 'editor';
|
||||
|
||||
export interface SandpackStylesProps {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface SandpackCustomizeProps {
|
||||
appCode?: string;
|
||||
dependencies?: string;
|
||||
template?: SandpackTemplate;
|
||||
layout?: SandpackLayout;
|
||||
showNavigator?: boolean;
|
||||
}
|
||||
|
||||
export type SandpackQueryFormData = QueryFormData &
|
||||
SandpackStylesProps &
|
||||
SandpackCustomizeProps & {
|
||||
metrics?: QueryFormMetric[] | null;
|
||||
groupby?: QueryFormMetric[] | null;
|
||||
all_columns?: QueryFormMetric[] | null;
|
||||
};
|
||||
|
||||
export type SandpackProps = SandpackStylesProps &
|
||||
SandpackCustomizeProps & {
|
||||
data: TimeseriesDataRecord[];
|
||||
formData: SandpackQueryFormData;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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 { SandpackChartPlugin } from '../src';
|
||||
|
||||
describe('@superset-ui/plugin-chart-sandpack', () => {
|
||||
test('exists', () => {
|
||||
expect(SandpackChartPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../..",
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"declarationDir": "lib"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||
"exclude": [
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.test.*",
|
||||
"src/**/*.stories.*"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../packages/superset-core" },
|
||||
{ "path": "../../packages/superset-ui-core" },
|
||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||
]
|
||||
}
|
||||
23
superset-frontend/plugins/plugin-chart-sandpack/types/external.d.ts
vendored
Normal file
23
superset-frontend/plugins/plugin-chart-sandpack/types/external.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
declare module '*.png' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
declare module '*.jpg';
|
||||
@@ -552,6 +552,12 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'column_config',
|
||||
@@ -648,18 +654,12 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars'),
|
||||
label: t('Show cell bars for all columns'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -673,7 +673,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'align_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Align +/-'),
|
||||
label: t('Align +/- for all columns'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t(
|
||||
@@ -687,7 +687,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
label: t('Add colors to cell bars for +/- for all columns'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, act } from 'spec/helpers/testing-library';
|
||||
import { StatusIndicatorDot } from './StatusIndicatorDot';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { getStatusConfig, StatusIndicatorDot } from './StatusIndicatorDot';
|
||||
import { AutoRefreshStatus } from '../../types/autoRefresh';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -62,6 +63,15 @@ test('renders with paused status', () => {
|
||||
expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Paused);
|
||||
});
|
||||
|
||||
test('uses the icon color for the paused status outline', () => {
|
||||
expect(
|
||||
getStatusConfig(supersetTheme, AutoRefreshStatus.Paused),
|
||||
).toMatchObject({
|
||||
needsBorder: true,
|
||||
outlineColor: 'currentColor',
|
||||
});
|
||||
});
|
||||
|
||||
test('has correct accessibility attributes', () => {
|
||||
render(<StatusIndicatorDot status={AutoRefreshStatus.Success} />);
|
||||
const dot = screen.getByTestId('status-indicator-dot');
|
||||
|
||||
@@ -39,9 +39,10 @@ export interface StatusIndicatorDotProps {
|
||||
interface StatusConfig {
|
||||
color: string;
|
||||
needsBorder: boolean;
|
||||
outlineColor?: string;
|
||||
}
|
||||
|
||||
const getStatusConfig = (
|
||||
export const getStatusConfig = (
|
||||
theme: ReturnType<typeof useTheme>,
|
||||
status: AutoRefreshStatus,
|
||||
): StatusConfig => {
|
||||
@@ -75,6 +76,7 @@ const getStatusConfig = (
|
||||
return {
|
||||
color: theme.colorBgContainer,
|
||||
needsBorder: true,
|
||||
outlineColor: 'currentColor',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
@@ -136,13 +138,15 @@ export const StatusIndicatorDot: FC<StatusIndicatorDotProps> = ({
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
color: ${theme.colorTextSecondary};
|
||||
background-color: ${statusConfig.color};
|
||||
transition:
|
||||
background-color ${theme.motionDurationMid} ease-in-out,
|
||||
border-color ${theme.motionDurationMid} ease-in-out;
|
||||
border: ${statusConfig.needsBorder
|
||||
? `1px solid ${theme.colorBorder}`
|
||||
: 'none'};
|
||||
border: ${statusConfig.needsBorder ? '1px solid' : 'none'};
|
||||
border-color: ${statusConfig.needsBorder
|
||||
? statusConfig.outlineColor
|
||||
: 'transparent'};
|
||||
box-shadow: ${statusConfig.needsBorder
|
||||
? 'none'
|
||||
: `0 0 0 2px ${theme.colorBgContainer}`};
|
||||
|
||||
@@ -21,6 +21,10 @@ import { VizType } from '@superset-ui/core';
|
||||
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
|
||||
import { exploreInitialData } from '../fixtures';
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
test('creates hydrate action from initial data', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
@@ -168,6 +172,84 @@ test('creates hydrate action with existing state', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('hydrates sliceName from preview form data before saved slice name', () => {
|
||||
window.history.pushState({}, '', '/explore/?form_data_key=preview-key');
|
||||
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
const previewSliceName = 'RENAMED - Bug Evidence';
|
||||
const savedSliceName = 'Most Populated Countries';
|
||||
const previewInitialData = {
|
||||
...exploreInitialData,
|
||||
form_data: {
|
||||
...exploreInitialData.form_data,
|
||||
slice_name: previewSliceName,
|
||||
},
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
slice_name: savedSliceName,
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error we only need the fields consumed by hydrateExplore
|
||||
hydrateExplore(previewInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: expect.objectContaining({
|
||||
explore: expect.objectContaining({
|
||||
sliceName: previewSliceName,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('hydrates sliceName from saved slice when regular form data has stale name', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
const staleFormDataSliceName = 'Stale Params Name';
|
||||
const savedSliceName = 'Current Saved Name';
|
||||
const savedChartInitialData = {
|
||||
...exploreInitialData,
|
||||
form_data: {
|
||||
...exploreInitialData.form_data,
|
||||
slice_name: staleFormDataSliceName,
|
||||
},
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
slice_name: savedSliceName,
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error we only need the fields consumed by hydrateExplore
|
||||
hydrateExplore(savedChartInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: expect.objectContaining({
|
||||
explore: expect.objectContaining({
|
||||
sliceName: savedSliceName,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses configured default time range if not set', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
|
||||
@@ -77,6 +77,12 @@ export const hydrateExplore =
|
||||
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
|
||||
const initialSlice = slice ?? fallbackSlice;
|
||||
const initialFormData = form_data ?? initialSlice?.form_data;
|
||||
const isCachedFormData = getUrlParam(URL_PARAMS.formDataKey) !== null;
|
||||
const [primarySliceNameSource, fallbackSliceNameSource] = isCachedFormData
|
||||
? [initialFormData, initialSlice]
|
||||
: [initialSlice, initialFormData];
|
||||
const initialSliceName =
|
||||
primarySliceNameSource?.slice_name ?? fallbackSliceNameSource?.slice_name;
|
||||
if (!initialFormData.viz_type) {
|
||||
const defaultVizType = common?.conf.DEFAULT_VIZ_TYPE || VizType.Table;
|
||||
initialFormData.viz_type =
|
||||
@@ -183,6 +189,7 @@ export const hydrateExplore =
|
||||
// because `bootstrapData.controls` is undefined.
|
||||
controls: initialControls,
|
||||
form_data: initialFormData,
|
||||
sliceName: initialSliceName,
|
||||
slice: initialSlice,
|
||||
controlsTransferred: explore.controlsTransferred,
|
||||
standalone: getUrlParam(URL_PARAMS.standalone),
|
||||
|
||||
@@ -179,6 +179,33 @@ test('renders the right footer buttons', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('initializes chart name from current Explore slice name', () => {
|
||||
const previewSliceName = 'RENAMED - Bug Evidence';
|
||||
const savedSliceName = 'Most Populated Countries';
|
||||
const { getByTestId } = setup(
|
||||
{
|
||||
...defaultProps,
|
||||
form_data: {
|
||||
...defaultProps.form_data,
|
||||
slice_name: previewSliceName,
|
||||
},
|
||||
sliceName: previewSliceName,
|
||||
},
|
||||
mockStore({
|
||||
...initialState,
|
||||
explore: {
|
||||
...initialState.explore,
|
||||
slice: {
|
||||
...initialState.explore.slice,
|
||||
slice_name: savedSliceName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getByTestId('new-chart-name')).toHaveValue(previewSliceName);
|
||||
});
|
||||
|
||||
test('does not render a message when overriding', () => {
|
||||
const { getByRole, queryByRole } = setup();
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { CheckboxChangeEvent } from '@superset-ui/core/components/Checkbox/types
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import {
|
||||
Button,
|
||||
@@ -1824,7 +1823,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
fetchAndSetDB();
|
||||
redirectURL(makeUrl(`/sqllab?db=true`));
|
||||
// redirectURL() delegates to history.push; React Router's basename
|
||||
// already prefixes the application root, so pass a relative path.
|
||||
redirectURL('/sqllab?db=true');
|
||||
}}
|
||||
>
|
||||
{t('Query data in SQL Lab')}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { TableTab } from 'src/views/CRUD/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import { WelcomeTable } from './types';
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
@@ -59,7 +58,9 @@ const REDIRECTS = {
|
||||
create: {
|
||||
[WelcomeTable.Charts]: '/chart/add',
|
||||
[WelcomeTable.Dashboards]: '/dashboard/new',
|
||||
[WelcomeTable.SavedQueries]: makeUrl('/sqllab?new=true'),
|
||||
// navigateTo() applies the application root internally; keep this
|
||||
// relative so the prefix isn't added twice.
|
||||
[WelcomeTable.SavedQueries]: '/sqllab?new=true',
|
||||
},
|
||||
viewAll: {
|
||||
[WelcomeTable.Charts]: '/chart/list',
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
TelemetryPixel,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { ensureAppRoot, makeUrl } from 'src/utils/pathUtils';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
@@ -213,7 +213,10 @@ const RightMenu = ({
|
||||
},
|
||||
{
|
||||
label: t('SQL query'),
|
||||
url: makeUrl('/sqllab?new=true'),
|
||||
// Keep the URL relative so isFrontendRoute() matches and Link navigates
|
||||
// via React Router; the <Typography.Link> fallback applies ensureAppRoot
|
||||
// exactly once for non-frontend routes.
|
||||
url: '/sqllab?new=true',
|
||||
icon: <Icons.SearchOutlined data-test={`menu-item-${t('SQL query')}`} />,
|
||||
perm: 'can_sqllab',
|
||||
view: 'Superset',
|
||||
|
||||
@@ -25,11 +25,20 @@ import {
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import SavedQueryList from '.';
|
||||
|
||||
// Renders the current router pathname+search so tests can assert navigation.
|
||||
function LocationDisplay() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div data-test="location-display">{`${location.pathname}${location.search}`}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Increase default timeout
|
||||
jest.setTimeout(30000);
|
||||
|
||||
@@ -88,6 +97,7 @@ const renderList = (props = {}, storeOverrides = {}) =>
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<SavedQueryList user={mockUser} {...props} />
|
||||
<LocationDisplay />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{
|
||||
@@ -242,4 +252,39 @@ describe('SavedQueryList', () => {
|
||||
// Verify delete buttons are not shown
|
||||
expect(screen.queryByTestId('delete-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"+ Query" button pushes a router-relative path (subdirectory deployment)', async () => {
|
||||
// Simulate SUPERSET_APP_ROOT=/superset. ensureAppRoot/makeUrl read
|
||||
// applicationRoot() dynamically, so mocking it here makes the buggy code
|
||||
// path (makeUrl() around history.push) produce '/superset/sqllab?new=true'
|
||||
// instead of being a no-op. React Router's <Router basename> prefixes the
|
||||
// app root on its own, so history.push MUST receive a path without the
|
||||
// app-root prefix — otherwise navigation lands at /superset/superset/sqllab
|
||||
// and shows a blank page (sc-103661).
|
||||
const applicationRootSpy = jest
|
||||
.spyOn(getBootstrapData, 'applicationRoot')
|
||||
.mockReturnValue('/superset');
|
||||
|
||||
try {
|
||||
renderList();
|
||||
|
||||
await screen.findByTestId('saved_query-list-view');
|
||||
|
||||
const queryButton = await screen.findByRole('button', {
|
||||
name: /query/i,
|
||||
});
|
||||
fireEvent.click(queryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// The MemoryRouter in renderList uses the default ('/') basename, so
|
||||
// useLocation reflects exactly what history.push received. A correct
|
||||
// router-relative push produces '/sqllab?new=true'; a buggy push that
|
||||
// re-applied the app root would produce '/superset/sqllab?new=true'.
|
||||
const location = screen.getByTestId('location-display').textContent;
|
||||
expect(location).toBe('/sqllab?new=true');
|
||||
});
|
||||
} finally {
|
||||
applicationRootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,7 +223,9 @@ function SavedQueryList({
|
||||
name: t('Query'),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
history.push(makeUrl('/sqllab?new=true'));
|
||||
// React Router's basename already includes the application root; passing
|
||||
// a relative path ensures correct navigation under subdirectory deployments.
|
||||
history.push('/sqllab?new=true');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -245,7 +247,9 @@ function SavedQueryList({
|
||||
if (openInNewWindow) {
|
||||
window.open(makeUrl(`/sqllab?savedQueryId=${id}`));
|
||||
} else {
|
||||
history.push(makeUrl(`/sqllab?savedQueryId=${id}`));
|
||||
// React Router's basename already includes the application root; passing
|
||||
// a relative path ensures correct navigation under subdirectory deployments.
|
||||
history.push(`/sqllab?savedQueryId=${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -338,9 +342,7 @@ function SavedQueryList({
|
||||
row: {
|
||||
original: { id, label },
|
||||
},
|
||||
}: any) => (
|
||||
<Link to={makeUrl(`/sqllab?savedQueryId=${id}`)}>{label}</Link>
|
||||
),
|
||||
}: any) => <Link to={`/sqllab?savedQueryId=${id}`}>{label}</Link>,
|
||||
id: 'label',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
} from 'src/chartCustomizations/components';
|
||||
import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table';
|
||||
import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars';
|
||||
import { SandpackChartPlugin } from '@superset-ui/plugin-chart-sandpack';
|
||||
import { ChartCustomizationPlugins, FilterPlugins } from 'src/constants';
|
||||
import AgGridTableChartPlugin from '@superset-ui/plugin-chart-ag-grid-table';
|
||||
import TimeTableChartPlugin from '../TimeTable';
|
||||
@@ -195,6 +196,7 @@ export default class MainPreset extends Preset {
|
||||
new EchartsTreeChartPlugin().configure({ key: VizType.Tree }),
|
||||
new EchartsSunburstChartPlugin().configure({ key: VizType.Sunburst }),
|
||||
new HandlebarsChartPlugin().configure({ key: VizType.Handlebars }),
|
||||
new SandpackChartPlugin().configure({ key: VizType.Sandpack }),
|
||||
new EchartsBubbleChartPlugin().configure({ key: VizType.Bubble }),
|
||||
new CartodiagramPlugin({
|
||||
defaultLayers: [
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
{ "path": "./plugins/plugin-chart-echarts" },
|
||||
{ "path": "./plugins/plugin-chart-point-cluster-map" },
|
||||
{ "path": "./plugins/plugin-chart-handlebars" },
|
||||
{ "path": "./plugins/plugin-chart-sandpack" },
|
||||
{ "path": "./plugins/plugin-chart-pivot-table" },
|
||||
{ "path": "./plugins/plugin-chart-table" },
|
||||
{ "path": "./plugins/plugin-chart-word-cloud" }
|
||||
|
||||
@@ -96,6 +96,8 @@ Available Resources:
|
||||
Available Prompts:
|
||||
- quickstart: Interactive guide for getting started with the MCP service
|
||||
- create_chart_guided: Step-by-step chart creation wizard
|
||||
- create_sandpack_app: Guidance for authoring chart_type="sandpack" apps
|
||||
(./data.json contract, template/dependencies, common patterns)
|
||||
|
||||
IMPORTANT - Using Saved Metrics vs Columns:
|
||||
When get_dataset_info returns a dataset, it includes both 'columns' and 'metrics'.
|
||||
@@ -217,6 +219,16 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
|
||||
Requires handlebars_template with Handlebars HTML template string.
|
||||
Supports query_mode="aggregate" (with metrics/groupby) or "raw" (with columns).
|
||||
Data available as {{{{data}}}} array; helpers: dateFormat, formatNumber, stringify.
|
||||
- chart_type="sandpack": In-browser Sandpack app rendered against the query result.
|
||||
Requires app_code (entry-file source). Inside the sandbox the dataset is exposed
|
||||
as ./data.json — import it with `import data from './data.json';`.
|
||||
Pick template: 'react' (default, JS, entry App.js with default export),
|
||||
'react-ts' (TS, App.tsx), 'vanilla' (index.js), or 'vanilla-ts' (index.ts).
|
||||
Add npm packages via dependencies={{...}} (e.g. {{"recharts": "^2.12.0"}}).
|
||||
Defaults to query_mode="raw" with columns; switch to "aggregate" with metrics
|
||||
for grouped data. Use this when the user wants a custom interactive UI that
|
||||
the built-in chart types can't express. Call get_chart_type_schema(chart_type
|
||||
="sandpack") for the full schema and runnable examples.
|
||||
|
||||
Time grain for temporal x-axis (time_grain parameter):
|
||||
- PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly)
|
||||
|
||||
@@ -37,6 +37,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
MixedTimeseriesChartConfig,
|
||||
PieChartConfig,
|
||||
PivotTableChartConfig,
|
||||
SandpackChartConfig,
|
||||
TableChartConfig,
|
||||
XYChartConfig,
|
||||
)
|
||||
@@ -315,6 +316,7 @@ def map_config_to_form_data(
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig
|
||||
| SandpackChartConfig
|
||||
| BigNumberChartConfig,
|
||||
dataset_id: int | str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -331,6 +333,8 @@ def map_config_to_form_data(
|
||||
return map_mixed_timeseries_config(config, dataset_id=dataset_id)
|
||||
elif isinstance(config, HandlebarsChartConfig):
|
||||
return map_handlebars_config(config)
|
||||
elif isinstance(config, SandpackChartConfig):
|
||||
return map_sandpack_config(config)
|
||||
elif isinstance(config, BigNumberChartConfig):
|
||||
if config.show_trendline and config.temporal_column:
|
||||
if not is_column_truly_temporal(config.temporal_column, dataset_id):
|
||||
@@ -824,6 +828,41 @@ def map_handlebars_config(config: HandlebarsChartConfig) -> Dict[str, Any]:
|
||||
return form_data
|
||||
|
||||
|
||||
def map_sandpack_config(config: SandpackChartConfig) -> Dict[str, Any]:
|
||||
"""Map sandpack chart config to Superset form_data.
|
||||
|
||||
Mirrors the camelCase keys the frontend control panel writes:
|
||||
``appCode``, ``dependencies`` (JSON-stringified), ``template``, ``layout``,
|
||||
``showNavigator``.
|
||||
"""
|
||||
form_data: Dict[str, Any] = {
|
||||
"viz_type": "sandpack",
|
||||
"appCode": config.app_code,
|
||||
"template": config.template,
|
||||
"layout": config.layout,
|
||||
"showNavigator": config.show_navigator,
|
||||
"row_limit": config.row_limit,
|
||||
}
|
||||
|
||||
if config.dependencies:
|
||||
form_data["dependencies"] = json.dumps(config.dependencies)
|
||||
|
||||
if config.query_mode == "raw":
|
||||
form_data["query_mode"] = "raw"
|
||||
if config.columns:
|
||||
form_data["all_columns"] = [col.name for col in config.columns]
|
||||
else:
|
||||
form_data["query_mode"] = "aggregate"
|
||||
if config.groupby:
|
||||
form_data["groupby"] = [col.name for col in config.groupby]
|
||||
if config.metrics:
|
||||
form_data["metrics"] = [create_metric_object(col) for col in config.metrics]
|
||||
|
||||
_add_adhoc_filters(form_data, config.filters)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
def map_pivot_table_config(config: PivotTableChartConfig) -> Dict[str, Any]:
|
||||
"""Map pivot table config to Superset form_data."""
|
||||
if not config.rows:
|
||||
@@ -1111,6 +1150,17 @@ def _handlebars_chart_what(config: HandlebarsChartConfig) -> str:
|
||||
return "Handlebars Chart"
|
||||
|
||||
|
||||
def _sandpack_chart_what(config: SandpackChartConfig) -> str:
|
||||
"""Build the 'what' portion for a sandpack chart name."""
|
||||
if config.query_mode == "raw" and config.columns:
|
||||
cols = ", ".join(col.name for col in config.columns[:3])
|
||||
return f"Sandpack ({cols})"
|
||||
if config.metrics:
|
||||
metrics = ", ".join(col.name for col in config.metrics[:3])
|
||||
return f"Sandpack ({metrics})"
|
||||
return "Sandpack App"
|
||||
|
||||
|
||||
def _big_number_chart_what(config: BigNumberChartConfig) -> str:
|
||||
"""Build the 'what' portion for a big number chart name.
|
||||
|
||||
@@ -1135,6 +1185,7 @@ def generate_chart_name(
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig
|
||||
| SandpackChartConfig
|
||||
| BigNumberChartConfig,
|
||||
dataset_name: str | None = None,
|
||||
) -> str:
|
||||
@@ -1169,6 +1220,9 @@ def generate_chart_name(
|
||||
elif isinstance(config, HandlebarsChartConfig):
|
||||
what = _handlebars_chart_what(config)
|
||||
context = _summarize_filters(getattr(config, "filters", None))
|
||||
elif isinstance(config, SandpackChartConfig):
|
||||
what = _sandpack_chart_what(config)
|
||||
context = _summarize_filters(getattr(config, "filters", None))
|
||||
elif isinstance(config, BigNumberChartConfig):
|
||||
what = _big_number_chart_what(config)
|
||||
context = _summarize_filters(getattr(config, "filters", None))
|
||||
@@ -1203,6 +1257,8 @@ def _resolve_viz_type(config: Any) -> str:
|
||||
return "mixed_timeseries"
|
||||
elif chart_type == "handlebars":
|
||||
return "handlebars"
|
||||
elif chart_type == "sandpack":
|
||||
return "sandpack"
|
||||
elif chart_type == "big_number":
|
||||
show_trendline = getattr(config, "show_trendline", False)
|
||||
temporal_column = getattr(config, "temporal_column", None)
|
||||
@@ -1292,6 +1348,10 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
|
||||
"Renders data using a custom Handlebars HTML template for "
|
||||
"fully flexible layouts like KPI cards, leaderboards, and reports"
|
||||
),
|
||||
"sandpack": (
|
||||
"Runs an in-browser Sandpack app (React/JS) bundled at view time, "
|
||||
"with the query result available inside the sandbox as ./data.json"
|
||||
),
|
||||
"big_number": (
|
||||
"Displays a key metric with a trendline showing "
|
||||
"how the value changes over time"
|
||||
|
||||
@@ -19,3 +19,4 @@
|
||||
|
||||
# Import to register prompts when module is imported
|
||||
from . import create_chart_guided # noqa: F401
|
||||
from . import create_sandpack_app # noqa: F401
|
||||
|
||||
152
superset/mcp_service/chart/prompts/create_sandpack_app.py
Normal file
152
superset/mcp_service/chart/prompts/create_sandpack_app.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Guided prompt for authoring chart_type="sandpack" apps.
|
||||
"""
|
||||
|
||||
from superset_core.mcp.decorators import prompt
|
||||
|
||||
|
||||
@prompt("create_sandpack_app")
|
||||
async def create_sandpack_app_prompt(
|
||||
template: str = "react",
|
||||
) -> str:
|
||||
"""Guidance for writing a Sandpack chart app.
|
||||
|
||||
Args:
|
||||
template: Sandpack template — react, react-ts, vanilla, vanilla-ts.
|
||||
"""
|
||||
valid_templates = ("react", "react-ts", "vanilla", "vanilla-ts")
|
||||
template = template if template in valid_templates else "react"
|
||||
|
||||
entry_file = {
|
||||
"react": "App.js (default export of a React component)",
|
||||
"react-ts": "App.tsx (default export of a React component)",
|
||||
"vanilla": "index.js (top-level script — write to document.body)",
|
||||
"vanilla-ts": "index.ts (top-level script — write to document.body)",
|
||||
}[template]
|
||||
|
||||
return f"""**Authoring a Sandpack Chart**
|
||||
|
||||
Sandpack charts let you ship a small, self-contained app rendered against the
|
||||
query result. The chart frame mounts the Sandpack bundler at view time.
|
||||
|
||||
## The contract — read this first
|
||||
|
||||
1. **Data is exposed as `./data.json`** inside the sandbox. The shape mirrors
|
||||
what the query returns (one row per array element, snake_case-style keys).
|
||||
2. **`app_code` is the entry file**. For `{template}` that file is **{entry_file}**.
|
||||
3. **Pick `query_mode` based on what your app needs**:
|
||||
- `query_mode="raw"` (default for sandpack) → row-level data via `columns`.
|
||||
- `query_mode="aggregate"` → grouped data via `metrics`/`groupby`.
|
||||
4. **Add npm packages with `dependencies`** (a `{{name: semver}}` object) only
|
||||
when you actually `import` them. React/ReactDOM ship with the react templates.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Pick a dataset
|
||||
Call `list_datasets` and `get_dataset_info(dataset_id)`. Note the column names
|
||||
exactly as Superset returns them; `./data.json` will use the same keys.
|
||||
|
||||
### Step 2 — Decide raw vs. aggregate
|
||||
- "Show me a list / a table / row-level details" → `query_mode="raw"`,
|
||||
set `columns: [...]`.
|
||||
- "Show me a chart of grouped numbers" → `query_mode="aggregate"`,
|
||||
set `metrics: [{{"name": "...", "aggregate": "SUM"}}]` and optionally `groupby`.
|
||||
In aggregate mode, metric keys in `./data.json` look like `"SUM(revenue)"`.
|
||||
|
||||
### Step 3 — Write `app_code`
|
||||
Always start with:
|
||||
```js
|
||||
import data from './data.json';
|
||||
```
|
||||
Then default-export a React component (for react/react-ts) or write to
|
||||
`document.body` (vanilla). Keep it one file when you can.
|
||||
|
||||
### Step 4 — Submit via `generate_chart` or `generate_explore_link`
|
||||
```json
|
||||
{{
|
||||
"dataset_id": <id>,
|
||||
"config": {{
|
||||
"chart_type": "sandpack",
|
||||
"template": "{template}",
|
||||
"query_mode": "raw",
|
||||
"columns": [{{"name": "<column>"}}],
|
||||
"app_code": "...",
|
||||
"dependencies": {{"recharts": "^2.12.0"}}
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
## Worked examples
|
||||
|
||||
### Minimal list (raw mode, react)
|
||||
```js
|
||||
import data from './data.json';
|
||||
|
||||
export default function App() {{
|
||||
return (
|
||||
<ul>
|
||||
{{data.map((row, i) => (
|
||||
<li key={{i}}>{{JSON.stringify(row)}}</li>
|
||||
))}}
|
||||
</ul>
|
||||
);
|
||||
}}
|
||||
```
|
||||
|
||||
### Grouped bar chart (aggregate mode, react + recharts)
|
||||
- `metrics: [{{"name": "revenue", "aggregate": "SUM"}}]`, `groupby: [{{"name": "region"}}]`
|
||||
- `dependencies: {{"recharts": "^2.12.0"}}`
|
||||
```js
|
||||
import data from './data.json';
|
||||
import {{ BarChart, Bar, XAxis, YAxis, Tooltip }} from 'recharts';
|
||||
|
||||
export default function App() {{
|
||||
return (
|
||||
<BarChart width={{500}} height={{300}} data={{data}}>
|
||||
<XAxis dataKey="region" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="SUM(revenue)" />
|
||||
</BarChart>
|
||||
);
|
||||
}}
|
||||
```
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Wrong key in aggregate mode**: in aggregate mode keys are `"SUM(col)"`,
|
||||
`"COUNT(col)"`, etc. — not the bare column name. Inspect a row first.
|
||||
- **Forgot `import data from './data.json'`**: the bundle still loads, but the
|
||||
app has no data. Always import the JSON.
|
||||
- **Used a package without declaring it**: every imported package needs an entry
|
||||
in `dependencies` (except react/react-dom on react templates).
|
||||
- **Multi-file apps**: only `app_code` is editable from MCP today. If you need
|
||||
multiple files, inline them into one entry file or open the chart in Explore
|
||||
to edit further.
|
||||
- **Heavy bundles**: Sandpack rebundles on every prop change. Avoid pulling in
|
||||
charting kitchen-sink libraries when a small one will do.
|
||||
|
||||
## Verification
|
||||
|
||||
After calling `generate_chart` or `generate_explore_link`, open the URL it
|
||||
returns in a browser. If the preview shows a Sandpack error overlay, fix the
|
||||
`app_code` (syntax, missing dependency, wrong data key) and re-call the tool —
|
||||
the chart's `form_data` is rewritten on each call.
|
||||
"""
|
||||
@@ -1054,6 +1054,137 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class SandpackChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
chart_type: Literal["sandpack"] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Chart type discriminator - MUST be 'sandpack' for in-browser apps "
|
||||
"rendered with the Sandpack bundler. The chart frame mounts a "
|
||||
"Sandpack runtime; the dataset is exposed inside the sandbox as "
|
||||
"`./data.json`. Use this for fully custom interactive UIs (small "
|
||||
"React apps, dashboards-in-a-cell, bespoke widgets) when the "
|
||||
"built-in chart types are too restrictive."
|
||||
),
|
||||
)
|
||||
app_code: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Source code for the entry file of the Sandpack app. Import the "
|
||||
"query result with `import data from './data.json';` (default-export "
|
||||
"the React component for `react`/`react-ts` templates). Keep it a "
|
||||
"single file when possible — Sandpack rebundles on every edit."
|
||||
),
|
||||
min_length=1,
|
||||
max_length=100000,
|
||||
validation_alias=AliasChoices("app_code", "appCode"),
|
||||
)
|
||||
template: Literal["react", "react-ts", "vanilla", "vanilla-ts"] = Field(
|
||||
"react",
|
||||
description=(
|
||||
"Sandpack runtime template. 'react' = JS React (entry: App.js, "
|
||||
"default export). 'react-ts' = TS React (entry: App.tsx). "
|
||||
"'vanilla' / 'vanilla-ts' = plain JS/TS (entry: index.js / "
|
||||
"index.ts). Pick the one matching the syntax used in app_code."
|
||||
),
|
||||
)
|
||||
dependencies: Dict[str, str] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional npm dependencies as {name: semver} pairs. React/ReactDOM "
|
||||
"ship with the react templates; only add what your app additionally "
|
||||
"imports (e.g. {'recharts': '^2.12.0'})."
|
||||
),
|
||||
)
|
||||
layout: Literal["preview", "split", "editor"] = Field(
|
||||
"preview",
|
||||
description=(
|
||||
"Which Sandpack panes are visible in the chart frame. 'preview' = "
|
||||
"rendered output only (recommended for finished charts), 'split' = "
|
||||
"editor + preview side-by-side, 'editor' = code only."
|
||||
),
|
||||
)
|
||||
show_navigator: bool = Field(
|
||||
False,
|
||||
description="Show the Sandpack URL bar above the preview pane.",
|
||||
validation_alias=AliasChoices("show_navigator", "showNavigator"),
|
||||
)
|
||||
query_mode: Literal["aggregate", "raw"] = Field(
|
||||
"raw",
|
||||
description=(
|
||||
"Query mode: 'raw' returns individual rows (default for sandpack — "
|
||||
"most apps want row-level data), 'aggregate' groups with metrics."
|
||||
),
|
||||
)
|
||||
columns: list[ColumnRef] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Columns to fetch in raw mode (query_mode='raw'). These become the "
|
||||
"fields available on each object in `./data.json`."
|
||||
),
|
||||
)
|
||||
groupby: list[ColumnRef] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Group-by columns in aggregate mode (query_mode='aggregate'). "
|
||||
"Become the dimensions for aggregation."
|
||||
),
|
||||
validation_alias=AliasChoices("groupby", "group_by"),
|
||||
)
|
||||
metrics: list[ColumnRef] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Metrics to aggregate in aggregate mode. Each must have an "
|
||||
"aggregate function (SUM, COUNT, AVG, ...)."
|
||||
),
|
||||
)
|
||||
filters: list[FilterConfig] | None = Field(
|
||||
None, description="Filters to apply"
|
||||
)
|
||||
row_limit: int = Field(
|
||||
1000,
|
||||
description="Maximum number of rows passed to the app",
|
||||
ge=1,
|
||||
le=50000,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_fields(self) -> "SandpackChartConfig":
|
||||
"""Enforce that the right query fields are present for the chosen mode."""
|
||||
if self.query_mode == "raw":
|
||||
if not self.columns:
|
||||
raise ValueError(
|
||||
"Sandpack chart in 'raw' query mode requires 'columns'. "
|
||||
"Specify which columns to expose in ./data.json."
|
||||
)
|
||||
if self.metrics:
|
||||
raise ValueError(
|
||||
"Sandpack chart in 'raw' query mode does not use 'metrics'. "
|
||||
"Remove 'metrics' or switch to 'aggregate' query mode."
|
||||
)
|
||||
if self.groupby:
|
||||
raise ValueError(
|
||||
"Sandpack chart in 'raw' query mode does not use 'groupby'. "
|
||||
"Remove 'groupby' or switch to 'aggregate' query mode."
|
||||
)
|
||||
else:
|
||||
if not self.metrics:
|
||||
raise ValueError(
|
||||
"Sandpack chart in 'aggregate' query mode requires 'metrics'. "
|
||||
"Specify at least one metric with an aggregate function."
|
||||
)
|
||||
missing_agg = [m.name for m in self.metrics if not m.is_metric]
|
||||
if missing_agg:
|
||||
raise ValueError(
|
||||
f"Sandpack chart in 'aggregate' query mode requires an "
|
||||
f"aggregate function on every metric. Missing aggregate for: "
|
||||
f"{', '.join(missing_agg)}. "
|
||||
f"Use one of: SUM, COUNT, AVG, MIN, MAX, COUNT_DISTINCT, etc."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -1343,13 +1474,14 @@ ChartConfig = Annotated[
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig
|
||||
| SandpackChartConfig
|
||||
| BigNumberChartConfig,
|
||||
Field(
|
||||
discriminator="chart_type",
|
||||
description=(
|
||||
"Chart configuration - specify chart_type as 'xy', 'table', "
|
||||
"'pie', 'pivot_table', 'mixed_timeseries', 'handlebars', "
|
||||
"or 'big_number'"
|
||||
"'sandpack', or 'big_number'"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -199,12 +199,8 @@ async def get_chart_data( # noqa: C901
|
||||
|
||||
if not chart:
|
||||
await ctx.warning("Chart not found: identifier=%s" % (request.identifier,))
|
||||
safe_id = str(request.identifier)[:200]
|
||||
return ChartError(
|
||||
error=(
|
||||
f"No chart found with identifier: {safe_id}."
|
||||
" Use list_charts to get valid chart IDs."
|
||||
),
|
||||
error=f"No chart found with identifier: {request.identifier}",
|
||||
error_type="NotFound",
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
|
||||
from superset.extensions import event_logger
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.mcp_service.chart.ascii_charts import (
|
||||
generate_ascii_chart,
|
||||
generate_ascii_table,
|
||||
@@ -1140,6 +1140,15 @@ async def _get_chart_preview_internal( # noqa: C901
|
||||
)
|
||||
chart = find_chart_by_identifier(request.identifier)
|
||||
|
||||
# Eagerly refresh all attributes while the session is still
|
||||
# active. SQLAlchemy expires object attributes after any
|
||||
# commit; if a downstream operation commits before the strategy
|
||||
# classes access chart attributes, a DetachedInstanceError will
|
||||
# be raised. Calling refresh() here ensures all column values
|
||||
# are loaded into the object's __dict__ upfront.
|
||||
if chart is not None:
|
||||
db.session.refresh(chart)
|
||||
|
||||
# If not found and looks like a form_data_key, try transient
|
||||
if (
|
||||
not chart
|
||||
@@ -1192,22 +1201,8 @@ async def _get_chart_preview_internal( # noqa: C901
|
||||
|
||||
if not chart:
|
||||
await ctx.warning("Chart not found: identifier=%s" % (request.identifier,))
|
||||
safe_id = str(request.identifier)[:200]
|
||||
is_form_data_key = (
|
||||
isinstance(request.identifier, str)
|
||||
and len(request.identifier) > 8
|
||||
and not request.identifier.isdigit()
|
||||
)
|
||||
if is_form_data_key:
|
||||
recovery = (
|
||||
"If using a form_data_key, it may have expired — "
|
||||
"use generate_explore_link to get a fresh key, "
|
||||
"or use list_charts to find a saved chart by ID."
|
||||
)
|
||||
else:
|
||||
recovery = "Use list_charts to get valid chart IDs."
|
||||
return ChartError(
|
||||
error=f"No chart found with identifier: {safe_id}. {recovery}",
|
||||
error=f"No chart found with identifier: {request.identifier}",
|
||||
error_type="NotFound",
|
||||
)
|
||||
|
||||
@@ -1385,6 +1380,20 @@ async def _get_chart_preview_internal( # noqa: C901
|
||||
|
||||
return _sanitize_chart_preview_for_llm_context(result)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
# Catch DetachedInstanceError and other SQLAlchemy errors that can
|
||||
# surface when the ORM session expires or commits mid-request.
|
||||
await ctx.error(
|
||||
"Chart preview failed due to database session error: "
|
||||
"identifier=%s, error_type=%s, error=%s"
|
||||
% (request.identifier, type(e).__name__, str(e))
|
||||
)
|
||||
logger.exception("SQLAlchemy error in get_chart_preview: %s", e)
|
||||
return ChartError(
|
||||
error="Database session error while generating chart preview. "
|
||||
"Please retry the request.",
|
||||
error_type="InternalError",
|
||||
)
|
||||
except (
|
||||
CommandException,
|
||||
SupersetException,
|
||||
|
||||
@@ -33,6 +33,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
MixedTimeseriesChartConfig,
|
||||
PieChartConfig,
|
||||
PivotTableChartConfig,
|
||||
SandpackChartConfig,
|
||||
TableChartConfig,
|
||||
XYChartConfig,
|
||||
)
|
||||
@@ -47,6 +48,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
|
||||
"pivot_table": TypeAdapter(PivotTableChartConfig),
|
||||
"mixed_timeseries": TypeAdapter(MixedTimeseriesChartConfig),
|
||||
"handlebars": TypeAdapter(HandlebarsChartConfig),
|
||||
"sandpack": TypeAdapter(SandpackChartConfig),
|
||||
"big_number": TypeAdapter(BigNumberChartConfig),
|
||||
}
|
||||
|
||||
@@ -110,6 +112,49 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
|
||||
"handlebars_template": "{{#each data}}<p>{{customer_name}}</p>{{/each}}",
|
||||
},
|
||||
],
|
||||
"sandpack": [
|
||||
{
|
||||
"chart_type": "sandpack",
|
||||
"template": "react",
|
||||
"query_mode": "raw",
|
||||
"columns": [{"name": "customer_name"}, {"name": "revenue"}],
|
||||
"app_code": (
|
||||
"import data from './data.json';\n\n"
|
||||
"export default function App() {\n"
|
||||
" return (\n"
|
||||
" <ul>\n"
|
||||
" {data.map((row, i) => (\n"
|
||||
" <li key={i}>{row.customer_name}: {row.revenue}</li>\n"
|
||||
" ))}\n"
|
||||
" </ul>\n"
|
||||
" );\n"
|
||||
"}\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"chart_type": "sandpack",
|
||||
"template": "react",
|
||||
"query_mode": "aggregate",
|
||||
"groupby": [{"name": "region"}],
|
||||
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"dependencies": {"recharts": "^2.12.0"},
|
||||
"app_code": (
|
||||
"import data from './data.json';\n"
|
||||
"import { BarChart, Bar, XAxis, YAxis, Tooltip } "
|
||||
"from 'recharts';\n\n"
|
||||
"export default function App() {\n"
|
||||
" return (\n"
|
||||
" <BarChart width={400} height={300} data={data}>\n"
|
||||
" <XAxis dataKey=\"region\" />\n"
|
||||
" <YAxis />\n"
|
||||
" <Tooltip />\n"
|
||||
" <Bar dataKey=\"SUM(revenue)\" />\n"
|
||||
" </BarChart>\n"
|
||||
" );\n"
|
||||
"}\n"
|
||||
),
|
||||
},
|
||||
],
|
||||
"big_number": [
|
||||
{
|
||||
"chart_type": "big_number",
|
||||
@@ -179,7 +224,7 @@ def get_chart_type_schema(
|
||||
for a chart configuration before calling generate_chart or update_chart.
|
||||
|
||||
Valid chart_type values: xy, table, pie, pivot_table,
|
||||
mixed_timeseries, handlebars, big_number.
|
||||
mixed_timeseries, handlebars, sandpack, big_number.
|
||||
|
||||
Returns the JSON Schema for the requested chart type, optionally
|
||||
with working examples.
|
||||
|
||||
@@ -337,18 +337,17 @@ async def update_chart( # noqa: C901
|
||||
chart = find_chart_by_identifier(request.identifier)
|
||||
|
||||
if not chart:
|
||||
safe_id = str(request.identifier)[:200]
|
||||
not_found_msg = (
|
||||
f"No chart found with identifier: {safe_id}."
|
||||
" Use list_charts to get valid chart IDs."
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": {
|
||||
"error_type": "NotFound",
|
||||
"message": not_found_msg,
|
||||
"details": not_found_msg,
|
||||
"message": (
|
||||
f"No chart found with identifier: {request.identifier}"
|
||||
),
|
||||
"details": (
|
||||
f"No chart found with identifier: {request.identifier}"
|
||||
),
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
BigNumberChartConfig,
|
||||
ColumnRef,
|
||||
HandlebarsChartConfig,
|
||||
SandpackChartConfig,
|
||||
MixedTimeseriesChartConfig,
|
||||
PieChartConfig,
|
||||
PivotTableChartConfig,
|
||||
@@ -302,6 +303,13 @@ class DatasetValidator:
|
||||
refs.extend(config.groupby)
|
||||
if config.metrics:
|
||||
refs.extend(config.metrics)
|
||||
elif isinstance(config, SandpackChartConfig):
|
||||
if config.columns:
|
||||
refs.extend(config.columns)
|
||||
if config.groupby:
|
||||
refs.extend(config.groupby)
|
||||
if config.metrics:
|
||||
refs.extend(config.metrics)
|
||||
elif isinstance(config, BigNumberChartConfig):
|
||||
refs.append(config.metric)
|
||||
if config.temporal_column:
|
||||
|
||||
@@ -134,6 +134,8 @@ class SchemaValidator:
|
||||
"Add 'chart_type': 'pivot_table' for interactive pivot tables",
|
||||
"Add 'chart_type': 'mixed_timeseries' for dual-series time charts",
|
||||
"Add 'chart_type': 'handlebars' for custom HTML template charts",
|
||||
"Add 'chart_type': 'sandpack' for in-browser Sandpack apps "
|
||||
"(React/JS) rendered against the query result",
|
||||
"Add 'chart_type': 'big_number' for big number display",
|
||||
"Example: 'config': {'chart_type': 'xy', ...}",
|
||||
],
|
||||
@@ -155,6 +157,7 @@ class SchemaValidator:
|
||||
"pivot_table": SchemaValidator._pre_validate_pivot_table_config,
|
||||
"mixed_timeseries": SchemaValidator._pre_validate_mixed_timeseries_config,
|
||||
"handlebars": SchemaValidator._pre_validate_handlebars_config,
|
||||
"sandpack": SchemaValidator._pre_validate_sandpack_config,
|
||||
"big_number": SchemaValidator._pre_validate_big_number_config,
|
||||
}
|
||||
|
||||
@@ -172,6 +175,7 @@ class SchemaValidator:
|
||||
"Use 'chart_type': 'pivot_table' for interactive pivot tables",
|
||||
"Use 'chart_type': 'mixed_timeseries' for dual-series time charts",
|
||||
"Use 'chart_type': 'handlebars' for custom HTML template charts",
|
||||
"Use 'chart_type': 'sandpack' for Sandpack-rendered React/JS apps",
|
||||
"Use 'chart_type': 'big_number' for big number display",
|
||||
"Check spelling and ensure lowercase",
|
||||
],
|
||||
@@ -358,6 +362,130 @@ class SchemaValidator:
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _pre_validate_sandpack_config(
|
||||
config: Dict[str, Any],
|
||||
) -> Tuple[bool, ChartGenerationError | None]:
|
||||
"""Pre-validate sandpack chart configuration."""
|
||||
# Accept both snake_case and the camelCase form_data alias.
|
||||
app_code = config.get("app_code") or config.get("appCode")
|
||||
if app_code is None:
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_app_code",
|
||||
message="Sandpack chart missing required field: app_code",
|
||||
details="Sandpack charts require an 'app_code' string with the "
|
||||
"source code of the entry file. Inside the sandbox the "
|
||||
"dataset is exposed as './data.json'.",
|
||||
suggestions=[
|
||||
"Add 'app_code' with the source for your entry file",
|
||||
"React entry: import data from './data.json'; "
|
||||
"export default function App() { ... }",
|
||||
"Vanilla entry: import data from './data.json'; "
|
||||
"document.body.innerText = JSON.stringify(data);",
|
||||
],
|
||||
error_code="MISSING_APP_CODE",
|
||||
)
|
||||
|
||||
if not isinstance(app_code, str) or not app_code.strip():
|
||||
return False, ChartGenerationError(
|
||||
error_type="invalid_app_code",
|
||||
message="Sandpack app_code must be a non-empty string",
|
||||
details="The 'app_code' field must contain the source code for "
|
||||
"the entry file as a non-empty string.",
|
||||
suggestions=[
|
||||
"Ensure app_code is a non-empty string",
|
||||
"Example: \"import data from './data.json';\\n"
|
||||
"export default function App(){return <pre>{JSON."
|
||||
'stringify(data)}</pre>}"',
|
||||
],
|
||||
error_code="INVALID_APP_CODE",
|
||||
)
|
||||
|
||||
template = config.get("template", "react")
|
||||
valid_templates = ("react", "react-ts", "vanilla", "vanilla-ts")
|
||||
if template not in valid_templates:
|
||||
return False, ChartGenerationError(
|
||||
error_type="invalid_template",
|
||||
message=f"Invalid Sandpack template: '{template}'",
|
||||
details=f"template must be one of: {', '.join(valid_templates)}",
|
||||
suggestions=[
|
||||
"Use 'react' (default) for JS React apps",
|
||||
"Use 'react-ts' for TypeScript React",
|
||||
"Use 'vanilla' or 'vanilla-ts' for plain JS/TS",
|
||||
],
|
||||
error_code="INVALID_SANDPACK_TEMPLATE",
|
||||
)
|
||||
|
||||
dependencies = config.get("dependencies")
|
||||
if dependencies is not None and not isinstance(dependencies, dict):
|
||||
return False, ChartGenerationError(
|
||||
error_type="invalid_dependencies",
|
||||
message="Sandpack dependencies must be an object",
|
||||
details="The 'dependencies' field must be an object mapping "
|
||||
"package names to semver ranges, or omitted entirely.",
|
||||
suggestions=[
|
||||
"Pass an object: {'recharts': '^2.12.0'}",
|
||||
"Or omit 'dependencies' to use only template defaults",
|
||||
],
|
||||
error_code="INVALID_SANDPACK_DEPENDENCIES",
|
||||
)
|
||||
|
||||
layout = config.get("layout", "preview")
|
||||
if layout not in ("preview", "split", "editor"):
|
||||
return False, ChartGenerationError(
|
||||
error_type="invalid_layout",
|
||||
message=f"Invalid Sandpack layout: '{layout}'",
|
||||
details="layout must be 'preview', 'split', or 'editor'",
|
||||
suggestions=[
|
||||
"Use 'preview' for finished charts (default)",
|
||||
"Use 'split' for editor + preview side-by-side",
|
||||
"Use 'editor' to show only the code",
|
||||
],
|
||||
error_code="INVALID_SANDPACK_LAYOUT",
|
||||
)
|
||||
|
||||
query_mode = config.get("query_mode", "raw")
|
||||
if query_mode not in ("aggregate", "raw"):
|
||||
return False, ChartGenerationError(
|
||||
error_type="invalid_query_mode",
|
||||
message="Invalid query_mode for sandpack chart",
|
||||
details="query_mode must be either 'aggregate' or 'raw'",
|
||||
suggestions=[
|
||||
"Use 'raw' for row-level data (default for sandpack)",
|
||||
"Use 'aggregate' with 'metrics' for grouped data",
|
||||
],
|
||||
error_code="INVALID_QUERY_MODE",
|
||||
)
|
||||
|
||||
if query_mode == "raw" and not config.get("columns"):
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_raw_columns",
|
||||
message="Sandpack chart in 'raw' mode requires 'columns'",
|
||||
details="When query_mode is 'raw', you must specify which "
|
||||
"columns to include in ./data.json",
|
||||
suggestions=[
|
||||
"Add 'columns': [{'name': 'column_name'}]",
|
||||
"Or use query_mode='aggregate' with 'metrics' "
|
||||
"and optional 'groupby'",
|
||||
],
|
||||
error_code="MISSING_RAW_COLUMNS",
|
||||
)
|
||||
|
||||
if query_mode == "aggregate" and not config.get("metrics"):
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_aggregate_metrics",
|
||||
message="Sandpack chart in 'aggregate' mode requires 'metrics'",
|
||||
details="When query_mode is 'aggregate', specify at least one "
|
||||
"metric with an aggregate function",
|
||||
suggestions=[
|
||||
"Add 'metrics': [{'name': 'col', 'aggregate': 'SUM'}]",
|
||||
"Or use query_mode='raw' with 'columns' for individual rows",
|
||||
],
|
||||
error_code="MISSING_AGGREGATE_METRICS",
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _pre_validate_big_number_config(
|
||||
config: Dict[str, Any],
|
||||
@@ -591,6 +719,26 @@ class SchemaValidator:
|
||||
],
|
||||
error_code="HANDLEBARS_VALIDATION_ERROR",
|
||||
)
|
||||
elif chart_type == "sandpack":
|
||||
return ChartGenerationError(
|
||||
error_type="sandpack_validation_error",
|
||||
message="Sandpack chart configuration validation failed",
|
||||
details="The sandpack chart configuration is missing "
|
||||
"required fields or has invalid structure",
|
||||
suggestions=[
|
||||
"Ensure 'app_code' is a non-empty string",
|
||||
"Pick a template: 'react' (default), 'react-ts', "
|
||||
"'vanilla', or 'vanilla-ts'",
|
||||
"For raw mode (default): set 'columns'",
|
||||
"For aggregate mode: set 'metrics' (and optionally "
|
||||
"'groupby') and switch 'query_mode' to 'aggregate'",
|
||||
"Example: {'chart_type': 'sandpack', 'app_code': "
|
||||
"\"import data from './data.json'; export default "
|
||||
"() => <pre>{JSON.stringify(data)}</pre>\", "
|
||||
"'columns': [{'name': 'product'}]}",
|
||||
],
|
||||
error_code="SANDPACK_VALIDATION_ERROR",
|
||||
)
|
||||
elif chart_type == "big_number":
|
||||
return ChartGenerationError(
|
||||
error_type="big_number_validation_error",
|
||||
|
||||
@@ -334,10 +334,7 @@ def _find_and_authorize_dashboard(
|
||||
dashboard=None,
|
||||
dashboard_url=None,
|
||||
position=None,
|
||||
error=(
|
||||
f"Dashboard with ID {dashboard_id} not found."
|
||||
" Use list_dashboards to get valid dashboard IDs."
|
||||
),
|
||||
error=f"Dashboard with ID {dashboard_id} not found",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -395,10 +392,7 @@ def add_chart_to_existing_dashboard(
|
||||
dashboard=None,
|
||||
dashboard_url=None,
|
||||
position=None,
|
||||
error=(
|
||||
f"Chart with ID {request.chart_id} not found."
|
||||
" Use list_charts to get valid chart IDs."
|
||||
),
|
||||
error=f"Chart with ID {request.chart_id} not found",
|
||||
)
|
||||
|
||||
# Validate dataset access for the chart.
|
||||
|
||||
@@ -230,10 +230,7 @@ def generate_dashboard( # noqa: C901
|
||||
return GenerateDashboardResponse(
|
||||
dashboard=None,
|
||||
dashboard_url=None,
|
||||
error=(
|
||||
f"Charts not found: {list(missing_chart_ids)}."
|
||||
" Use list_charts to get valid chart IDs."
|
||||
),
|
||||
error=f"Charts not found: {list(missing_chart_ids)}",
|
||||
)
|
||||
|
||||
# Validate dataset access for each chart.
|
||||
|
||||
@@ -183,10 +183,7 @@ async def query_dataset( # noqa: C901
|
||||
if dataset is None:
|
||||
await ctx.error("Dataset not found: identifier=%s" % (request.dataset_id,))
|
||||
return DatasetError.create(
|
||||
error=(
|
||||
f"No dataset found with identifier: {request.dataset_id}."
|
||||
" Use list_datasets to get valid dataset IDs."
|
||||
),
|
||||
error=f"No dataset found with identifier: {request.dataset_id}",
|
||||
error_type="NotFound",
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ from superset.mcp_service.constants import (
|
||||
DEFAULT_TOKEN_LIMIT,
|
||||
DEFAULT_WARN_THRESHOLD_PCT,
|
||||
)
|
||||
from superset.mcp_service.utils.token_utils import (
|
||||
estimate_response_tokens,
|
||||
format_size_limit_error,
|
||||
INFO_TOOLS,
|
||||
truncate_oversized_response,
|
||||
)
|
||||
from superset.utils.core import get_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1104,11 +1110,6 @@ class ResponseSizeGuardMiddleware(Middleware):
|
||||
``content[0].text`` as a JSON string. We parse that string, run the
|
||||
truncation phases on the resulting dict, then re-wrap the result.
|
||||
"""
|
||||
from superset.mcp_service.utils.token_utils import (
|
||||
estimate_response_tokens,
|
||||
truncate_oversized_response,
|
||||
)
|
||||
|
||||
# Unwrap ToolResult so truncation operates on the real payload
|
||||
extracted = self._extract_payload_from_tool_result(response)
|
||||
if extracted is not None:
|
||||
@@ -1191,12 +1192,6 @@ class ResponseSizeGuardMiddleware(Middleware):
|
||||
# Execute the tool
|
||||
response = await call_next(context)
|
||||
|
||||
# Estimate response token count (guard against huge responses causing OOM)
|
||||
from superset.mcp_service.utils.token_utils import (
|
||||
estimate_response_tokens,
|
||||
format_size_limit_error,
|
||||
)
|
||||
|
||||
# When the response is a ToolResult, estimate tokens on the actual
|
||||
# payload inside content[0].text rather than on the ToolResult
|
||||
# wrapper (which would double-serialize the JSON string).
|
||||
@@ -1233,8 +1228,6 @@ class ResponseSizeGuardMiddleware(Middleware):
|
||||
params = getattr(context.message, "params", {}) or {}
|
||||
|
||||
# For info tools, try dynamic truncation before blocking
|
||||
from superset.mcp_service.utils.token_utils import INFO_TOOLS
|
||||
|
||||
if tool_name in INFO_TOOLS:
|
||||
truncated = self._try_truncate_info_response(
|
||||
tool_name, response, estimated_tokens
|
||||
|
||||
@@ -100,10 +100,7 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
|
||||
)
|
||||
return ExecuteSqlResponse(
|
||||
success=False,
|
||||
error=(
|
||||
f"Database with ID {request.database_id} not found."
|
||||
" Use list_databases to get valid database IDs."
|
||||
),
|
||||
error=f"Database with ID {request.database_id} not found",
|
||||
error_type=SupersetErrorType.DATABASE_NOT_FOUND_ERROR.value,
|
||||
)
|
||||
|
||||
|
||||
@@ -103,8 +103,7 @@ def open_sql_lab_with_context(
|
||||
database = DatabaseDAO.find_by_id(request.database_connection_id)
|
||||
if not database:
|
||||
error_message = (
|
||||
f"Database with ID {request.database_connection_id} not found."
|
||||
" Use list_databases to get valid database IDs."
|
||||
f"Database with ID {request.database_connection_id} not found"
|
||||
)
|
||||
return _sanitize_sql_lab_response_for_llm_context(
|
||||
SqlLabResponse(
|
||||
|
||||
@@ -21,6 +21,26 @@ Token counting and response size utilities for MCP service.
|
||||
This module provides utilities to estimate token counts and generate smart
|
||||
suggestions when responses exceed configured limits. This prevents large
|
||||
responses from overwhelming LLM clients like Claude Desktop.
|
||||
|
||||
Token counting strategy:
|
||||
|
||||
1. ``tiktoken`` with the ``cl100k_base`` encoding when the package is
|
||||
installed (it is shipped as part of the ``fastmcp`` extra). This is a
|
||||
real BPE tokenizer trained on a similar vocabulary to Claude's; for
|
||||
English and JSON-heavy MCP payloads it tracks Claude's tokenizer
|
||||
within roughly ±10%, which is far more accurate than the legacy
|
||||
character heuristic.
|
||||
2. A character-based fallback (``CHARS_PER_TOKEN``) when tiktoken is not
|
||||
importable. The fallback uses a slightly more conservative ratio than
|
||||
before (3.0 chars/token instead of 3.5) so that JSON-heavy responses
|
||||
are not under-counted, which previously let oversized payloads slip
|
||||
past the response-size guard.
|
||||
|
||||
The exact-Claude tokenizer is only available via Anthropic's network
|
||||
``count_tokens`` API; calling it from a synchronous middleware on every
|
||||
tool result is too slow and adds an external dependency on every
|
||||
response. ``tiktoken`` is the closest approximation we can ship without
|
||||
that risk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,18 +56,63 @@ logger = logging.getLogger(__name__)
|
||||
# Type alias for MCP tool responses (Pydantic models, dicts, lists, strings, bytes)
|
||||
ToolResponse: TypeAlias = Union[BaseModel, Dict[str, Any], List[Any], str, bytes]
|
||||
|
||||
# Approximate characters per token for estimation
|
||||
# Claude tokenizer averages ~4 chars per token for English text
|
||||
# JSON tends to be more verbose, so we use a slightly lower ratio
|
||||
CHARS_PER_TOKEN = 3.5
|
||||
# Fallback character-to-token ratio used when tiktoken is unavailable.
|
||||
# 3.0 is conservative for JSON content (the previous 3.5 under-counted
|
||||
# JSON-heavy payloads relative to Claude's actual tokenizer, which let
|
||||
# oversized responses slip past the response-size guard).
|
||||
CHARS_PER_TOKEN = 3.0
|
||||
|
||||
# Encoding used when tiktoken is available. cl100k_base is OpenAI's
|
||||
# tokenizer for GPT-3.5/4; it is BPE-based with a vocabulary similar to
|
||||
# Claude's and tracks Claude's token counts within roughly ±10% for
|
||||
# English and JSON-heavy MCP responses.
|
||||
_TIKTOKEN_ENCODING_NAME = "cl100k_base"
|
||||
|
||||
|
||||
def _load_tiktoken_encoding() -> Any:
|
||||
"""Return a tiktoken encoding instance, or None if tiktoken is unavailable.
|
||||
|
||||
Imported lazily so the module can be used in environments without
|
||||
tiktoken installed. The encoding is small (~1 MB) so we cache it on
|
||||
first use.
|
||||
"""
|
||||
try:
|
||||
import tiktoken
|
||||
except ImportError:
|
||||
logger.info(
|
||||
"tiktoken not installed; falling back to char-based token "
|
||||
"estimation (CHARS_PER_TOKEN=%s). Install the 'fastmcp' extra "
|
||||
"for accurate counts.",
|
||||
CHARS_PER_TOKEN,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return tiktoken.get_encoding(_TIKTOKEN_ENCODING_NAME)
|
||||
except (KeyError, ValueError) as exc:
|
||||
# tiktoken installed but the requested encoding is missing — this
|
||||
# only happens on partial installs. Treat as no tokenizer rather
|
||||
# than crashing on every tool call.
|
||||
logger.warning(
|
||||
"tiktoken encoding '%s' unavailable: %s; falling back to "
|
||||
"char-based token estimation",
|
||||
_TIKTOKEN_ENCODING_NAME,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Cached encoding instance (None if tiktoken not importable).
|
||||
_ENCODING = _load_tiktoken_encoding()
|
||||
|
||||
|
||||
def estimate_token_count(text: str | bytes) -> int:
|
||||
"""
|
||||
Estimate the token count for a given text.
|
||||
|
||||
Uses a character-based heuristic since we don't have direct access to
|
||||
the actual tokenizer. This is conservative to avoid underestimating.
|
||||
Uses tiktoken's ``cl100k_base`` encoding when available for
|
||||
Claude-aligned accuracy (within ~10%), falling back to a
|
||||
character-based heuristic otherwise.
|
||||
|
||||
Args:
|
||||
text: The text to estimate tokens for (string or bytes)
|
||||
@@ -58,11 +123,19 @@ def estimate_token_count(text: str | bytes) -> int:
|
||||
if isinstance(text, bytes):
|
||||
text = text.decode("utf-8", errors="replace")
|
||||
|
||||
# Simple heuristic: ~3.5 characters per token for JSON/code
|
||||
text_length = len(text)
|
||||
if text_length == 0:
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, int(text_length / CHARS_PER_TOKEN))
|
||||
|
||||
if _ENCODING is not None:
|
||||
try:
|
||||
return len(_ENCODING.encode(text))
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
# Defensive: if tiktoken chokes on a specific input, fall
|
||||
# back to the char heuristic for this call rather than
|
||||
# raising — the response size guard must never fail-open.
|
||||
logger.warning("tiktoken encode failed (%s); using fallback", exc)
|
||||
|
||||
return max(1, int(len(text) / CHARS_PER_TOKEN))
|
||||
|
||||
|
||||
def estimate_response_tokens(response: ToolResponse) -> int:
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
OpenSearch SQL dialect.
|
||||
|
||||
OpenSearch SQL is syntactically close to MySQL but accepts both backticks and
|
||||
double-quotes as identifier delimiters. Treating ``"`` as an identifier (rather
|
||||
than a string delimiter, as MySQL does) is what keeps mixed-case column names
|
||||
from being emitted as string literals after a SQLGlot round-trip.
|
||||
double-quotes as identifier delimiters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,4 +29,4 @@ from sqlglot.dialects.mysql import MySQL
|
||||
|
||||
class OpenSearch(MySQL):
|
||||
class Tokenizer(MySQL.Tokenizer):
|
||||
IDENTIFIERS = ['"', "`"]
|
||||
IDENTIFIERS = ["`", '"']
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
color: #000;
|
||||
}
|
||||
{% endif %}
|
||||
{% if standalone_mode %}
|
||||
/* Keep body sized so screenshot waits don't see it as hidden before React mounts. */
|
||||
html, body.standalone {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
{% endif %}
|
||||
</style>
|
||||
|
||||
{% if dark_theme_bg and entry != 'embedded' %}
|
||||
|
||||
@@ -68,3 +68,50 @@ def test_spa_template_includes_css_bundles():
|
||||
"spa.html must call css_bundle for the page entry to load "
|
||||
"entry-specific extracted CSS in production builds"
|
||||
)
|
||||
|
||||
|
||||
def test_spa_template_standalone_body_has_min_height():
|
||||
"""Standalone body must be measurable so screenshot waits don't time out."""
|
||||
from jinja2 import DictLoader, Environment
|
||||
|
||||
template_path = join(SUPERSET_DIR, "templates", "superset", "spa.html")
|
||||
with open(template_path) as f:
|
||||
template_content = f.read()
|
||||
|
||||
env = Environment( # noqa: S701
|
||||
loader=DictLoader(
|
||||
{
|
||||
"spa.html": template_content,
|
||||
# Stub out includes/imports that are not relevant for this test.
|
||||
"appbuilder/general/lib.html": "",
|
||||
"superset/partials/asset_bundle.html": (
|
||||
"{% macro css_bundle(prefix, entry) %}{% endmacro %}"
|
||||
"{% macro js_bundle(prefix, entry) %}{% endmacro %}"
|
||||
),
|
||||
"superset/macros.html": ("{% macro get_nonce() %}{% endmacro %}"),
|
||||
"tail_js_custom_extra.html": "",
|
||||
"head_custom_extra.html": "",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
appbuilder = Mock()
|
||||
appbuilder.app.config = {"FAVICONS": []}
|
||||
|
||||
def render(standalone_mode: bool) -> str:
|
||||
return env.get_template("spa.html").render(
|
||||
appbuilder=appbuilder,
|
||||
assets_prefix="",
|
||||
bootstrap_data="{}",
|
||||
entry="spa",
|
||||
standalone_mode=standalone_mode,
|
||||
theme_tokens={},
|
||||
spinner_svg=None,
|
||||
)
|
||||
|
||||
standalone_html = render(standalone_mode=True)
|
||||
assert "body.standalone" in standalone_html
|
||||
assert "min-height: 100vh" in standalone_html
|
||||
|
||||
non_standalone_html = render(standalone_mode=False)
|
||||
assert "body.standalone" not in non_standalone_html
|
||||
|
||||
423
tests/unit_tests/mcp_service/chart/test_sandpack_chart.py
Normal file
423
tests/unit_tests/mcp_service/chart/test_sandpack_chart.py
Normal file
@@ -0,0 +1,423 @@
|
||||
# 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 Sandpack chart type support in MCP service."""
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.mcp_service.chart.chart_utils import (
|
||||
_resolve_viz_type,
|
||||
analyze_chart_capabilities,
|
||||
analyze_chart_semantics,
|
||||
generate_chart_name,
|
||||
map_config_to_form_data,
|
||||
map_sandpack_config,
|
||||
)
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ColumnRef,
|
||||
FilterConfig,
|
||||
SandpackChartConfig,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.schema_validator import SchemaValidator
|
||||
from superset.utils import json
|
||||
|
||||
DEFAULT_APP = "import data from './data.json';\nexport default () => null;\n"
|
||||
|
||||
|
||||
class TestSandpackChartConfig:
|
||||
def test_minimal_raw_config(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
assert config.chart_type == "sandpack"
|
||||
assert config.template == "react"
|
||||
assert config.layout == "preview"
|
||||
assert config.query_mode == "raw"
|
||||
assert config.row_limit == 1000
|
||||
|
||||
def test_minimal_aggregate_config(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
query_mode="aggregate",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
assert config.query_mode == "aggregate"
|
||||
|
||||
def test_app_code_alias(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
appCode=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
assert config.app_code == DEFAULT_APP
|
||||
|
||||
def test_show_navigator_alias(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
showNavigator=True,
|
||||
)
|
||||
assert config.show_navigator is True
|
||||
|
||||
def test_app_code_min_length(self) -> None:
|
||||
with pytest.raises(ValueError, match="at least 1 character"):
|
||||
SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code="",
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
|
||||
def test_raw_mode_requires_columns(self) -> None:
|
||||
with pytest.raises(ValueError, match="requires 'columns'"):
|
||||
SandpackChartConfig(chart_type="sandpack", app_code=DEFAULT_APP)
|
||||
|
||||
def test_aggregate_mode_requires_metrics(self) -> None:
|
||||
with pytest.raises(ValueError, match="requires 'metrics'"):
|
||||
SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
query_mode="aggregate",
|
||||
)
|
||||
|
||||
def test_aggregate_mode_requires_aggregate_function(self) -> None:
|
||||
with pytest.raises(ValueError, match="Missing aggregate for"):
|
||||
SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
query_mode="aggregate",
|
||||
metrics=[ColumnRef(name="sales")],
|
||||
)
|
||||
|
||||
def test_raw_mode_rejects_metrics(self) -> None:
|
||||
with pytest.raises(ValueError, match="does not use 'metrics'"):
|
||||
SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
query_mode="raw",
|
||||
columns=[ColumnRef(name="product")],
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
|
||||
def test_extra_fields_forbidden(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unknown field"):
|
||||
SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
bogus_field="x",
|
||||
)
|
||||
|
||||
def test_invalid_template_rejected_by_pydantic(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
template="vue", # not a valid Literal value
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
|
||||
|
||||
class TestMapSandpackConfig:
|
||||
def test_raw_mode_basic(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product"), ColumnRef(name="price")],
|
||||
)
|
||||
result = map_sandpack_config(config)
|
||||
|
||||
assert result["viz_type"] == "sandpack"
|
||||
assert result["appCode"] == DEFAULT_APP
|
||||
assert result["template"] == "react"
|
||||
assert result["layout"] == "preview"
|
||||
assert result["showNavigator"] is False
|
||||
assert result["query_mode"] == "raw"
|
||||
assert result["all_columns"] == ["product", "price"]
|
||||
assert "metrics" not in result
|
||||
assert "groupby" not in result
|
||||
|
||||
def test_aggregate_mode_with_groupby(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
query_mode="aggregate",
|
||||
groupby=[ColumnRef(name="region")],
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
result = map_sandpack_config(config)
|
||||
|
||||
assert result["query_mode"] == "aggregate"
|
||||
assert result["groupby"] == ["region"]
|
||||
assert result["metrics"][0]["aggregate"] == "SUM"
|
||||
|
||||
def test_dependencies_serialized_to_json_string(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
dependencies={"recharts": "^2.12.0"},
|
||||
)
|
||||
result = map_sandpack_config(config)
|
||||
|
||||
assert isinstance(result["dependencies"], str)
|
||||
assert json.loads(result["dependencies"]) == {"recharts": "^2.12.0"}
|
||||
|
||||
def test_no_dependencies_omits_key(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
assert "dependencies" not in map_sandpack_config(config)
|
||||
|
||||
def test_with_filters(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
filters=[FilterConfig(column="status", op="=", value="active")],
|
||||
)
|
||||
result = map_sandpack_config(config)
|
||||
|
||||
assert "adhoc_filters" in result
|
||||
assert result["adhoc_filters"][0]["subject"] == "status"
|
||||
assert result["adhoc_filters"][0]["comparator"] == "active"
|
||||
|
||||
def test_layout_and_navigator_pass_through(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
layout="split",
|
||||
show_navigator=True,
|
||||
)
|
||||
result = map_sandpack_config(config)
|
||||
assert result["layout"] == "split"
|
||||
assert result["showNavigator"] is True
|
||||
|
||||
|
||||
class TestMapConfigToFormDataSandpack:
|
||||
def test_dispatches_sandpack_config(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
result = map_config_to_form_data(config)
|
||||
assert result["viz_type"] == "sandpack"
|
||||
|
||||
|
||||
class TestGenerateChartNameSandpack:
|
||||
def test_raw_mode_uses_columns(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product"), ColumnRef(name="price")],
|
||||
)
|
||||
name = generate_chart_name(config)
|
||||
assert "Sandpack" in name
|
||||
assert "product" in name
|
||||
|
||||
def test_aggregate_mode_uses_metrics(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
query_mode="aggregate",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
name = generate_chart_name(config)
|
||||
assert "Sandpack" in name
|
||||
assert "sales" in name
|
||||
|
||||
|
||||
class TestResolveVizTypeSandpack:
|
||||
def test_sandpack(self) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
config = MagicMock(chart_type="sandpack")
|
||||
assert _resolve_viz_type(config) == "sandpack"
|
||||
|
||||
|
||||
class TestAnalyzeChartCapabilitiesSandpack:
|
||||
def test_sandpack_capabilities(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
caps = analyze_chart_capabilities(None, config)
|
||||
|
||||
assert caps.supports_export is True
|
||||
assert "url" in caps.optimal_formats
|
||||
|
||||
|
||||
class TestAnalyzeChartSemanticsSandpack:
|
||||
def test_sandpack_semantics(self) -> None:
|
||||
config = SandpackChartConfig(
|
||||
chart_type="sandpack",
|
||||
app_code=DEFAULT_APP,
|
||||
columns=[ColumnRef(name="product")],
|
||||
)
|
||||
semantics = analyze_chart_semantics(None, config)
|
||||
assert semantics.primary_insight is not None
|
||||
|
||||
|
||||
class TestSchemaValidatorSandpack:
|
||||
def test_minimal_raw_accepted(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"app_code": DEFAULT_APP,
|
||||
"columns": [{"name": "product"}],
|
||||
},
|
||||
}
|
||||
is_valid, request, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is True
|
||||
assert request is not None
|
||||
assert error is None
|
||||
|
||||
def test_camelcase_app_code_alias(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"appCode": DEFAULT_APP,
|
||||
"columns": [{"name": "product"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_missing_app_code(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"columns": [{"name": "product"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_APP_CODE"
|
||||
|
||||
def test_empty_app_code(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"app_code": " ",
|
||||
"columns": [{"name": "product"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_APP_CODE"
|
||||
|
||||
def test_invalid_template(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"app_code": DEFAULT_APP,
|
||||
"template": "svelte",
|
||||
"columns": [{"name": "product"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_SANDPACK_TEMPLATE"
|
||||
|
||||
def test_dependencies_must_be_object(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"app_code": DEFAULT_APP,
|
||||
"dependencies": ["recharts"],
|
||||
"columns": [{"name": "product"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_SANDPACK_DEPENDENCIES"
|
||||
|
||||
def test_invalid_layout(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"app_code": DEFAULT_APP,
|
||||
"layout": "fullscreen",
|
||||
"columns": [{"name": "product"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_SANDPACK_LAYOUT"
|
||||
|
||||
def test_raw_mode_missing_columns(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"app_code": DEFAULT_APP,
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_RAW_COLUMNS"
|
||||
|
||||
def test_aggregate_mode_missing_metrics(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "sandpack",
|
||||
"app_code": DEFAULT_APP,
|
||||
"query_mode": "aggregate",
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_AGGREGATE_METRICS"
|
||||
|
||||
def test_invalid_chart_type_suggestions_mention_sandpack(self) -> None:
|
||||
data = {"dataset_id": 1, "config": {"chart_type": "invalid"}}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "sandpack" in (error.details or "")
|
||||
|
||||
def test_missing_chart_type_suggestions_mention_sandpack(self) -> None:
|
||||
data = {"dataset_id": 1, "config": {}}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
suggestions_text = " ".join(error.suggestions or [])
|
||||
assert "sandpack" in suggestions_text
|
||||
@@ -595,3 +595,191 @@ Market Share
|
||||
"""
|
||||
|
||||
# These demonstrate the expected ASCII formats for different chart types
|
||||
|
||||
|
||||
class TestDetachedInstanceError:
|
||||
"""Tests that DetachedInstanceError is handled gracefully.
|
||||
|
||||
When the SQLAlchemy session commits mid-request, ORM objects expire and
|
||||
become detached. Accessing lazy attributes on a detached Slice raises
|
||||
DetachedInstanceError. The tool must:
|
||||
1. Call db.session.refresh() immediately after loading the chart so all
|
||||
column values are loaded upfront before any downstream operation.
|
||||
2. Catch SQLAlchemyError (the base class) and return a ChartError
|
||||
instead of propagating the exception.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_refresh_called_after_chart_load(self):
|
||||
"""db.session.refresh() is invoked right after find_chart_by_identifier."""
|
||||
import importlib
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from superset.mcp_service.chart.schemas import URLPreview
|
||||
from superset.utils import json
|
||||
|
||||
get_chart_preview_module = importlib.import_module(
|
||||
"superset.mcp_service.chart.tool.get_chart_preview"
|
||||
)
|
||||
|
||||
mock_chart = MagicMock()
|
||||
mock_chart.id = 42
|
||||
mock_chart.slice_name = "Sales Chart"
|
||||
mock_chart.viz_type = "table"
|
||||
mock_chart.datasource_id = 1
|
||||
mock_chart.datasource_type = "table"
|
||||
mock_chart.params = "{}"
|
||||
|
||||
refresh_calls: list[object] = []
|
||||
|
||||
def _fake_refresh(obj: object) -> None:
|
||||
refresh_calls.append(obj)
|
||||
|
||||
url_preview = URLPreview(
|
||||
preview_url="http://localhost/explore/?slice_id=42",
|
||||
width=800,
|
||||
height=600,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
get_chart_preview_module,
|
||||
"find_chart_by_identifier",
|
||||
return_value=mock_chart,
|
||||
),
|
||||
patch.object(
|
||||
get_chart_preview_module.db,
|
||||
"session",
|
||||
**{"refresh.side_effect": _fake_refresh},
|
||||
),
|
||||
patch.object(
|
||||
get_chart_preview_module,
|
||||
"validate_chart_dataset",
|
||||
return_value=MagicMock(is_valid=True, warnings=[]),
|
||||
),
|
||||
patch.object(
|
||||
get_chart_preview_module.event_logger,
|
||||
"log_context",
|
||||
return_value=nullcontext(),
|
||||
),
|
||||
# Return a real URLPreview so Pydantic model validation succeeds
|
||||
patch.object(
|
||||
get_chart_preview_module.PreviewFormatGenerator,
|
||||
"generate",
|
||||
return_value=url_preview,
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.utils.url_utils.get_superset_base_url",
|
||||
return_value="http://localhost",
|
||||
),
|
||||
):
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.chart.schemas import GetChartPreviewRequest
|
||||
|
||||
with patch("superset.mcp_service.auth.get_user_from_request") as mu:
|
||||
mu.return_value = MagicMock(id=1, username="admin")
|
||||
with patch(
|
||||
"superset.mcp_service.auth.check_tool_permission", return_value=True
|
||||
):
|
||||
async with Client(mcp) as client:
|
||||
response = await client.call_tool(
|
||||
"get_chart_preview",
|
||||
{
|
||||
"request": GetChartPreviewRequest(
|
||||
identifier=42, format="url"
|
||||
).model_dump()
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(response.content[0].text)
|
||||
# The tool should succeed — not return a ChartError
|
||||
assert "error_type" not in data, (
|
||||
f"Expected ChartPreview but got ChartError: {data.get('error')}"
|
||||
)
|
||||
assert data.get("chart_id") == 42
|
||||
|
||||
assert len(refresh_calls) == 1, (
|
||||
"db.session.refresh() should be called once after loading the chart"
|
||||
)
|
||||
assert refresh_calls[0] is mock_chart
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detached_instance_error_returns_chart_error(self):
|
||||
"""DetachedInstanceError during preview generation returns ChartError."""
|
||||
import importlib
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||
|
||||
get_chart_preview_module = importlib.import_module(
|
||||
"superset.mcp_service.chart.tool.get_chart_preview"
|
||||
)
|
||||
|
||||
mock_chart = MagicMock()
|
||||
mock_chart.id = 7
|
||||
mock_chart.slice_name = "Broken Chart"
|
||||
mock_chart.viz_type = "bar"
|
||||
mock_chart.datasource_id = 3
|
||||
mock_chart.datasource_type = "table"
|
||||
mock_chart.params = "{}"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
get_chart_preview_module,
|
||||
"find_chart_by_identifier",
|
||||
return_value=mock_chart,
|
||||
),
|
||||
patch.object(
|
||||
get_chart_preview_module.db,
|
||||
"session",
|
||||
**{"refresh.return_value": None},
|
||||
),
|
||||
patch.object(
|
||||
get_chart_preview_module,
|
||||
"validate_chart_dataset",
|
||||
return_value=MagicMock(is_valid=True, warnings=[]),
|
||||
),
|
||||
patch.object(
|
||||
get_chart_preview_module.event_logger,
|
||||
"log_context",
|
||||
return_value=nullcontext(),
|
||||
),
|
||||
# Simulate the session expiring inside the strategy
|
||||
patch.object(
|
||||
get_chart_preview_module.PreviewFormatGenerator,
|
||||
"generate",
|
||||
side_effect=DetachedInstanceError(),
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.utils.url_utils.get_superset_base_url",
|
||||
return_value="http://localhost",
|
||||
),
|
||||
):
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.chart.schemas import GetChartPreviewRequest
|
||||
from superset.utils import json
|
||||
|
||||
with patch("superset.mcp_service.auth.get_user_from_request") as mu:
|
||||
mu.return_value = MagicMock(id=1, username="admin")
|
||||
with patch(
|
||||
"superset.mcp_service.auth.check_tool_permission", return_value=True
|
||||
):
|
||||
async with Client(mcp) as client:
|
||||
response = await client.call_tool(
|
||||
"get_chart_preview",
|
||||
{
|
||||
"request": GetChartPreviewRequest(
|
||||
identifier=7, format="ascii"
|
||||
).model_dump()
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(response.content[0].text)
|
||||
assert data["error_type"] == "InternalError"
|
||||
assert "session" in data["error"].lower() or "retry" in data["error"].lower()
|
||||
|
||||
@@ -94,9 +94,10 @@ class TestGetChartTypeSchema:
|
||||
assert example["chart_type"] == "pie"
|
||||
|
||||
def test_valid_chart_types_constant(self) -> None:
|
||||
assert len(VALID_CHART_TYPES) == 7
|
||||
assert len(VALID_CHART_TYPES) == 8
|
||||
assert "xy" in VALID_CHART_TYPES
|
||||
assert "table" in VALID_CHART_TYPES
|
||||
assert "sandpack" in VALID_CHART_TYPES
|
||||
|
||||
def test_all_chart_types_have_examples(self) -> None:
|
||||
for chart_type in VALID_CHART_TYPES:
|
||||
|
||||
@@ -298,8 +298,7 @@ class TestOpenSqlLabWithContext:
|
||||
field_path=("title",),
|
||||
)
|
||||
assert response.error == sanitize_for_llm_context(
|
||||
"Database with ID 404 not found."
|
||||
" Use list_databases to get valid database IDs.",
|
||||
"Database with ID 404 not found",
|
||||
field_path=("error",),
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -146,7 +146,13 @@ class TestResponseSizeGuardMiddleware:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_warning_at_threshold(self) -> None:
|
||||
"""Should log warning when approaching limit."""
|
||||
"""Should log warning when approaching limit.
|
||||
|
||||
Mocks the token estimator to return a specific value above the
|
||||
warn threshold but below the hard limit, decoupling the test
|
||||
from whichever tokenizer (tiktoken or char heuristic) happens
|
||||
to be loaded.
|
||||
"""
|
||||
middleware = ResponseSizeGuardMiddleware(
|
||||
token_limit=1000, warn_threshold_pct=80
|
||||
)
|
||||
@@ -155,18 +161,21 @@ class TestResponseSizeGuardMiddleware:
|
||||
context.message.name = "list_charts"
|
||||
context.message.params = {}
|
||||
|
||||
# Response at ~85% of limit (should trigger warning but not block)
|
||||
response = {"data": "x" * 2900} # ~828 tokens at 3.5 chars/token
|
||||
response = {"data": "approaching the limit"}
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with (
|
||||
patch("superset.mcp_service.middleware.get_user_id", return_value=1),
|
||||
patch("superset.mcp_service.middleware.event_logger"),
|
||||
patch(
|
||||
"superset.mcp_service.middleware.estimate_response_tokens",
|
||||
return_value=850,
|
||||
),
|
||||
patch("superset.mcp_service.middleware.logger") as mock_logger,
|
||||
):
|
||||
result = await middleware.on_call_tool(context, call_next)
|
||||
|
||||
# Should return response (not blocked)
|
||||
# Should return response (not blocked at 85% of limit)
|
||||
assert result == response
|
||||
# Should log warning
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
@@ -20,9 +20,11 @@ Unit tests for MCP service token utilities.
|
||||
"""
|
||||
|
||||
from typing import Any, List
|
||||
from unittest.mock import patch
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from superset.mcp_service.utils import token_utils
|
||||
from superset.mcp_service.utils.token_utils import (
|
||||
_replace_collections_with_summaries,
|
||||
_summarize_large_dicts,
|
||||
@@ -45,29 +47,65 @@ class TestEstimateTokenCount:
|
||||
"""Test estimate_token_count function."""
|
||||
|
||||
def test_estimate_string(self) -> None:
|
||||
"""Should estimate tokens for a string."""
|
||||
"""Should produce a positive non-zero estimate for a normal string.
|
||||
|
||||
We don't assert on a specific number because the result depends on
|
||||
which tokenizer is loaded (tiktoken when available, char heuristic
|
||||
otherwise).
|
||||
"""
|
||||
text = "Hello world"
|
||||
result = estimate_token_count(text)
|
||||
expected = int(len(text) / CHARS_PER_TOKEN)
|
||||
assert result == expected
|
||||
assert result > 0
|
||||
|
||||
def test_estimate_bytes(self) -> None:
|
||||
"""Should estimate tokens for bytes."""
|
||||
text = b"Hello world"
|
||||
result = estimate_token_count(text)
|
||||
expected = int(len(text) / CHARS_PER_TOKEN)
|
||||
assert result == expected
|
||||
"""Bytes input should be decoded and produce the same count as the
|
||||
equivalent string."""
|
||||
text = "Hello world"
|
||||
assert estimate_token_count(text.encode("utf-8")) == estimate_token_count(text)
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
"""Should return 0 for empty string."""
|
||||
"""Should return 0 for empty string and empty bytes."""
|
||||
assert estimate_token_count("") == 0
|
||||
assert estimate_token_count(b"") == 0
|
||||
|
||||
def test_json_like_content(self) -> None:
|
||||
"""Should estimate tokens for JSON-like content."""
|
||||
"""JSON content should produce a positive estimate."""
|
||||
json_str = '{"name": "test", "value": 123, "items": [1, 2, 3]}'
|
||||
result = estimate_token_count(json_str)
|
||||
assert result > 0
|
||||
assert result == int(len(json_str) / CHARS_PER_TOKEN)
|
||||
assert estimate_token_count(json_str) > 0
|
||||
|
||||
def test_long_text_roughly_scales_with_length(self) -> None:
|
||||
"""A doubled string should produce roughly double the token count
|
||||
(within ±10%)."""
|
||||
small = "the quick brown fox jumps over the lazy dog. " * 20
|
||||
large = small * 2
|
||||
small_n = estimate_token_count(small)
|
||||
large_n = estimate_token_count(large)
|
||||
# Within 10% of 2x — both tokenizers (tiktoken and the char
|
||||
# fallback) preserve length monotonicity.
|
||||
assert 1.8 * small_n <= large_n <= 2.2 * small_n
|
||||
|
||||
def test_fallback_uses_chars_per_token_when_tiktoken_unavailable(
|
||||
self,
|
||||
) -> None:
|
||||
"""When the tiktoken encoding is None (not installed), the
|
||||
function falls back to len/CHARS_PER_TOKEN math."""
|
||||
text = "x" * 100
|
||||
with patch.object(token_utils, "_ENCODING", None):
|
||||
result = estimate_token_count(text)
|
||||
assert result == int(100 / CHARS_PER_TOKEN)
|
||||
|
||||
def test_fallback_when_tiktoken_encode_raises(self) -> None:
|
||||
"""A misbehaving encoding should fall back to the char heuristic
|
||||
rather than raise — the size guard must never fail-open."""
|
||||
|
||||
class BoomEncoding:
|
||||
def encode(self, text: str) -> list[int]:
|
||||
raise ValueError("simulated tiktoken failure")
|
||||
|
||||
text = "abc" * 50
|
||||
with patch.object(token_utils, "_ENCODING", BoomEncoding()):
|
||||
result = estimate_token_count(text)
|
||||
assert result == int(len(text) / CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
class TestEstimateResponseTokens:
|
||||
|
||||
@@ -33,7 +33,8 @@ def test_opensearch_dialect_registered() -> None:
|
||||
|
||||
def test_double_quotes_as_identifiers() -> None:
|
||||
"""
|
||||
Test that double quotes are treated as identifiers, not string literals.
|
||||
Test that double quotes are treated as identifiers, not string literals,
|
||||
and normalized to backticks in output.
|
||||
"""
|
||||
sql = 'SELECT "AvgTicketPrice" FROM "flights"'
|
||||
ast = sqlglot.parse_one(sql, OpenSearch)
|
||||
@@ -42,8 +43,8 @@ def test_double_quotes_as_identifiers() -> None:
|
||||
OpenSearch().generate(expression=ast, pretty=True)
|
||||
== """
|
||||
SELECT
|
||||
"AvgTicketPrice"
|
||||
FROM "flights"
|
||||
`AvgTicketPrice`
|
||||
FROM `flights`
|
||||
""".strip()
|
||||
)
|
||||
|
||||
@@ -69,8 +70,7 @@ WHERE
|
||||
|
||||
def test_backticks_as_identifiers() -> None:
|
||||
"""
|
||||
Test that backticks work as identifiers (MySQL-style).
|
||||
Backticks are normalized to double quotes in output.
|
||||
Test that backticks are accepted as identifiers and preserved on output.
|
||||
"""
|
||||
sql = "SELECT `AvgTicketPrice` FROM `flights`"
|
||||
ast = sqlglot.parse_one(sql, OpenSearch)
|
||||
@@ -79,15 +79,16 @@ def test_backticks_as_identifiers() -> None:
|
||||
OpenSearch().generate(expression=ast, pretty=True)
|
||||
== """
|
||||
SELECT
|
||||
"AvgTicketPrice"
|
||||
FROM "flights"
|
||||
`AvgTicketPrice`
|
||||
FROM `flights`
|
||||
""".strip()
|
||||
)
|
||||
|
||||
|
||||
def test_mixed_identifier_quotes() -> None:
|
||||
"""
|
||||
Test mixing double quotes and backticks for identifiers.
|
||||
Test mixing double quotes and backticks for identifiers are all normalized to
|
||||
backticks on output.
|
||||
"""
|
||||
sql = 'SELECT "AvgTicketPrice" AS `AvgTicketPrice` FROM `default`.`flights`'
|
||||
ast = sqlglot.parse_one(sql, OpenSearch)
|
||||
@@ -96,12 +97,26 @@ def test_mixed_identifier_quotes() -> None:
|
||||
OpenSearch().generate(expression=ast, pretty=True)
|
||||
== """
|
||||
SELECT
|
||||
"AvgTicketPrice" AS "AvgTicketPrice"
|
||||
FROM "default"."flights"
|
||||
`AvgTicketPrice` AS `AvgTicketPrice`
|
||||
FROM `default`.`flights`
|
||||
""".strip()
|
||||
)
|
||||
|
||||
|
||||
def test_alias_with_space() -> None:
|
||||
"""
|
||||
Test that an alias containing a space (e.g. a metric key like ``my test``)
|
||||
is preserved as a backtick-quoted identifier through the round-trip.
|
||||
"""
|
||||
sql = 'SELECT COUNT(*) AS "my test" FROM `flights`'
|
||||
ast = sqlglot.parse_one(sql, OpenSearch)
|
||||
|
||||
assert (
|
||||
OpenSearch().generate(expression=ast, pretty=False)
|
||||
== "SELECT COUNT(*) AS `my test` FROM `flights`"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, expected",
|
||||
[
|
||||
@@ -110,20 +125,20 @@ FROM "default"."flights"
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM "flights"
|
||||
FROM `flights`
|
||||
WHERE
|
||||
"Cancelled" = TRUE
|
||||
`Cancelled` = TRUE
|
||||
""".strip(),
|
||||
),
|
||||
(
|
||||
'SELECT "Carrier", SUM("AvgTicketPrice") FROM "flights" GROUP BY "Carrier"',
|
||||
"""
|
||||
SELECT
|
||||
"Carrier",
|
||||
SUM("AvgTicketPrice")
|
||||
FROM "flights"
|
||||
`Carrier`,
|
||||
SUM(`AvgTicketPrice`)
|
||||
FROM `flights`
|
||||
GROUP BY
|
||||
"Carrier"
|
||||
`Carrier`
|
||||
""".strip(),
|
||||
),
|
||||
(
|
||||
@@ -131,9 +146,9 @@ GROUP BY
|
||||
"""
|
||||
SELECT
|
||||
*
|
||||
FROM "flights"
|
||||
FROM `flights`
|
||||
WHERE
|
||||
"DestCountry" IN ('US', 'CA', 'MX')
|
||||
`DestCountry` IN ('US', 'CA', 'MX')
|
||||
""".strip(),
|
||||
),
|
||||
],
|
||||
@@ -165,13 +180,13 @@ GROUP BY "Carrier"
|
||||
OpenSearch().generate(expression=ast, pretty=True)
|
||||
== """
|
||||
SELECT
|
||||
"Carrier",
|
||||
`Carrier`,
|
||||
COUNT(*),
|
||||
AVG("AvgTicketPrice"),
|
||||
MAX("FlightDelayMin")
|
||||
FROM "flights"
|
||||
AVG(`AvgTicketPrice`),
|
||||
MAX(`FlightDelayMin`)
|
||||
FROM `flights`
|
||||
GROUP BY
|
||||
"Carrier"
|
||||
`Carrier`
|
||||
""".strip()
|
||||
)
|
||||
|
||||
@@ -190,10 +205,10 @@ SELECT
|
||||
*
|
||||
FROM (
|
||||
SELECT
|
||||
"Carrier",
|
||||
"AvgTicketPrice"
|
||||
FROM "flights"
|
||||
) AS "sub"
|
||||
`Carrier`,
|
||||
`AvgTicketPrice`
|
||||
FROM `flights`
|
||||
) AS `sub`
|
||||
""".strip()
|
||||
)
|
||||
|
||||
@@ -212,12 +227,12 @@ def test_order_by_with_quoted_identifiers() -> None:
|
||||
OpenSearch().generate(expression=ast, pretty=True)
|
||||
== """
|
||||
SELECT
|
||||
"Carrier",
|
||||
"AvgTicketPrice"
|
||||
FROM "flights"
|
||||
`Carrier`,
|
||||
`AvgTicketPrice`
|
||||
FROM `flights`
|
||||
ORDER BY
|
||||
"AvgTicketPrice" DESC,
|
||||
"Carrier" ASC
|
||||
`AvgTicketPrice` DESC,
|
||||
`Carrier` ASC
|
||||
""".strip()
|
||||
)
|
||||
|
||||
@@ -234,7 +249,7 @@ def test_limit_clause() -> None:
|
||||
== """
|
||||
SELECT
|
||||
*
|
||||
FROM "flights"
|
||||
FROM `flights`
|
||||
LIMIT 10
|
||||
""".strip()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user