Compare commits

..

12 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
a39e3f10be feat: sandbox plugin 2026-05-11 10:32:04 +03:00
Mehmet Salih Yavuz
69fbbfd7ce fix(table): consolidate visual column options under Visual formatting section (#39856) 2026-05-08 10:43:38 +03:00
Enzo Martellucci
d3784879c2 fix(embedded-sdk): grant fullscreen and clipboard-write by default (#39943) 2026-05-08 09:28:55 +02:00
Vitor Avila
ad5e3170dd fix: OpenSearch dialect identifier delimiters (#39953) 2026-05-07 16:19:27 -03:00
Maxime Beauchemin
aa710672ed fix(ui): remove makeUrl() double-prefix bugs under subdirectory deployment (#39503)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vitor Avila <96086495+Vitor-Avila@users.noreply.github.com>
2026-05-07 15:39:38 -03:00
Richard Fogaca Nienkotter
8c80caefa3 fix(explore): preserve preview chart name on save (#39908) 2026-05-07 13:08:28 -03:00
Richard Fogaca Nienkotter
8088c5d1de fix(dashboard): match auto-refresh paused-dot outline to icon color (#39909) 2026-05-07 13:07:52 -03:00
Amin Ghadersohi
9b520312a1 fix(mcp): use tiktoken for response-size-guard token estimation (#39912) 2026-05-07 11:51:31 -04:00
Amin Ghadersohi
9ac4711ac8 fix(mcp): prevent DetachedInstanceError in get_chart_preview (#39921) 2026-05-07 11:44:11 -04:00
dependabot[bot]
7593d2a164 chore(deps): bump caniuse-lite from 1.0.30001791 to 1.0.30001792 in /docs (#39933)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 21:57:29 +07:00
dependabot[bot]
d3c44e311e chore(deps): bump aws-actions/amazon-ecr-login from 2.1.4 to 2.1.5 (#39931)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 21:54:59 +07:00
Enzo Martellucci
b5186d1c65 fix(reports): keep body sized so standalone screenshots don't time out (#39944) 2026-05-07 12:26:50 +02:00
73 changed files with 2981 additions and 186 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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');

View File

@@ -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",

View File

@@ -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",

View File

@@ -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',

View File

@@ -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(

View 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"
}
}

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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>
);

View File

@@ -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

View 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';

View File

@@ -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,
},
]);
}

View File

@@ -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;

View File

@@ -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,
}),
},
};

View File

@@ -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,
}),
},
};

View File

@@ -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,
},
};

View File

@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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,
});
}
}

View File

@@ -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,
};
}

View 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;
};

View File

@@ -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();
});
});

View File

@@ -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" }
]
}

View 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';

View File

@@ -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(

View File

@@ -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');

View File

@@ -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}`};

View File

@@ -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(() => ({

View File

@@ -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),

View File

@@ -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();

View File

@@ -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')}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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();
}
});
});

View File

@@ -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',
},
{

View File

@@ -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: [

View File

@@ -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" }

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View 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.
"""

View File

@@ -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'"
),
),
]

View File

@@ -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",
)

View File

@@ -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,

View File

@@ -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.

View File

@@ -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",

View File

@@ -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:

View File

@@ -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",

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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 = ["`", '"']

View File

@@ -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' %}

View File

@@ -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

View 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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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:

View File

@@ -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()

View File

@@ -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:

View File

@@ -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()
)