mirror of
https://github.com/apache/superset.git
synced 2026-09-10 01:04:25 +00:00
Compare commits
59
Commits
@@ -76,7 +76,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@58dbe8ed6879f0d3b02ac295b20d5fdfe7733e0c # beta
|
||||
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics (if triggered by integration tests)
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
path: docs/src/data/
|
||||
- name: Try to download latest diagnostics (for push/dispatch triggers)
|
||||
if: github.event_name != 'workflow_run'
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
run: |
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics from integration tests
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -115,6 +115,10 @@ services:
|
||||
DATABASE_HOST: db-light
|
||||
DATABASE_DB: superset_light
|
||||
POSTGRES_DB: superset_light
|
||||
EXAMPLES_HOST: db-light
|
||||
EXAMPLES_DB: superset_light
|
||||
EXAMPLES_USER: superset
|
||||
EXAMPLES_PASSWORD: superset
|
||||
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
|
||||
GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}
|
||||
GITHUB_SHA: ${GITHUB_SHA:-}
|
||||
@@ -137,6 +141,10 @@ services:
|
||||
DATABASE_HOST: db-light
|
||||
DATABASE_DB: superset_light
|
||||
POSTGRES_DB: superset_light
|
||||
EXAMPLES_HOST: db-light
|
||||
EXAMPLES_DB: superset_light
|
||||
EXAMPLES_USER: superset
|
||||
EXAMPLES_PASSWORD: superset
|
||||
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
|
||||
healthcheck:
|
||||
disable: true
|
||||
@@ -157,6 +165,7 @@ services:
|
||||
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
|
||||
NPM_RUN_PRUNE: false
|
||||
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
||||
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
|
||||
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
||||
superset: "http://superset-light:8088"
|
||||
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
|
||||
|
||||
@@ -80,7 +80,7 @@ case "${1}" in
|
||||
;;
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
|
||||
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
;;
|
||||
app-gunicorn)
|
||||
echo "Starting web app..."
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
"@swc/core": "^1.15.21",
|
||||
"antd": "^6.3.5",
|
||||
"baseline-browser-mapping": "^2.10.13",
|
||||
"caniuse-lite": "^1.0.30001784",
|
||||
"caniuse-lite": "^1.0.30001786",
|
||||
"docusaurus-plugin-openapi-docs": "^4.6.0",
|
||||
"docusaurus-theme-openapi-docs": "^4.6.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
+4
-4
@@ -6067,10 +6067,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001784:
|
||||
version "1.0.30001784"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz#bdf9733a0813ccfb5ab4d02f2127e62ee4c6b718"
|
||||
integrity sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001786:
|
||||
version "1.0.30001786"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz#586120fc73f3c7ee82152f76acd0c37e04acefbb"
|
||||
integrity sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
|
||||
Generated
+186
-137
@@ -88,7 +88,7 @@
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"geolib": "^3.3.4",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.3.1",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.4.1",
|
||||
@@ -142,7 +142,7 @@
|
||||
"redux-undo": "^1.0.0-beta9-9-7",
|
||||
"rison": "^0.1.1",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"simple-zstd": "^2.1.0",
|
||||
"simple-zstd": "^1.4.2",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"urijs": "^1.19.8",
|
||||
@@ -189,8 +189,8 @@
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.21",
|
||||
"@swc/plugin-emotion": "^14.7.0",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@swc/plugin-emotion": "^14.8.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -220,7 +220,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"babel-jest": "^30.0.2",
|
||||
"babel-loader": "^10.1.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
@@ -259,7 +259,7 @@
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"lerna": "^9.0.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
@@ -462,44 +462,32 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz",
|
||||
"integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==",
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.6.tgz",
|
||||
"integrity": "sha512-BXWCh8dHs9GOfpo/fWGDJtDmleta2VePN9rn6WQt3GjEbxzutVF4t0x2pmH+7dbMCLtuv3MlwqRsAuxlzFXqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.1.1",
|
||||
"@csstools/css-color-parser": "^4.0.2",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.2.7"
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
|
||||
"integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.7.tgz",
|
||||
"integrity": "sha512-d2BgqDUOS1Hfp4IzKUZqCNz+Kg3Y88AkaBvJK/ZVSQPU1f7OpPNi7nQTH6/oI47Dkdg+Z3e8Yp6ynOu4UMINAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.7"
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
@@ -519,16 +507,6 @@
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
@@ -13192,15 +13170,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.21.tgz",
|
||||
"integrity": "sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz",
|
||||
"integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.25"
|
||||
"@swc/types": "^0.1.26"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -13210,18 +13188,18 @@
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.15.21",
|
||||
"@swc/core-darwin-x64": "1.15.21",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.21",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.21",
|
||||
"@swc/core-linux-arm64-musl": "1.15.21",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.21",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.21",
|
||||
"@swc/core-linux-x64-gnu": "1.15.21",
|
||||
"@swc/core-linux-x64-musl": "1.15.21",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.21",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.21",
|
||||
"@swc/core-win32-x64-msvc": "1.15.21"
|
||||
"@swc/core-darwin-arm64": "1.15.24",
|
||||
"@swc/core-darwin-x64": "1.15.24",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.24",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.24",
|
||||
"@swc/core-linux-arm64-musl": "1.15.24",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.24",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.24",
|
||||
"@swc/core-linux-x64-gnu": "1.15.24",
|
||||
"@swc/core-linux-x64-musl": "1.15.24",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.24",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.24",
|
||||
"@swc/core-win32-x64-msvc": "1.15.24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
@@ -13233,9 +13211,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz",
|
||||
"integrity": "sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz",
|
||||
"integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -13249,9 +13227,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz",
|
||||
"integrity": "sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz",
|
||||
"integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -13265,9 +13243,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz",
|
||||
"integrity": "sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz",
|
||||
"integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -13281,9 +13259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz",
|
||||
"integrity": "sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz",
|
||||
"integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -13297,9 +13275,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz",
|
||||
"integrity": "sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz",
|
||||
"integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -13313,9 +13291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz",
|
||||
"integrity": "sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz",
|
||||
"integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -13329,9 +13307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz",
|
||||
"integrity": "sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz",
|
||||
"integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -13345,9 +13323,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz",
|
||||
"integrity": "sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz",
|
||||
"integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -13361,9 +13339,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz",
|
||||
"integrity": "sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz",
|
||||
"integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -13377,9 +13355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz",
|
||||
"integrity": "sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz",
|
||||
"integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -13393,9 +13371,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz",
|
||||
"integrity": "sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz",
|
||||
"integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -13409,9 +13387,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz",
|
||||
"integrity": "sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==",
|
||||
"version": "1.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz",
|
||||
"integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -13450,9 +13428,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.7.0.tgz",
|
||||
"integrity": "sha512-RwYrsxia8GKh2qLHWwymcfCeP6C5gAkssB2YtBRhP/qlKCxXYfv808buEXkCYvyGIY+bN3XziKXCuAi+waA5pQ==",
|
||||
"version": "14.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.8.0.tgz",
|
||||
"integrity": "sha512-otFM4JfEE9uyH6HxhD5Dmw6WUY773d2Ln44kEobc89HoVbdGkO3DZv9r2h5znFy7wORyl894V3nYd/mhqc4dIQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -13470,9 +13448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.25",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
|
||||
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
|
||||
"version": "0.1.26",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
|
||||
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -18031,9 +18009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.1.0.tgz",
|
||||
"integrity": "sha512-5HTUZa013O4SWEYlJDHexrqSIYkWatfA9w/ZZQa7V2nMc0dRWkfu/0pmioC7XMYm8M7Z/3+q42NWj6e+fAT0MQ==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.1.1.tgz",
|
||||
"integrity": "sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -23118,6 +23096,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/duplex-maker": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/duplex-maker/-/duplex-maker-1.0.0.tgz",
|
||||
"integrity": "sha512-KoHuzggxg7f+vvjqOHfXxaQYI1POzBm+ah0eec7YDssZmbt6QFBI8d1nl5GQwAgR2f+VQCPvyvZtmWWqWuFtlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
@@ -23163,6 +23147,54 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
|
||||
"integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"readable-stream": "^2.0.0",
|
||||
"stream-shift": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/duplexify/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/duplexify/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
@@ -23333,7 +23365,6 @@
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
@@ -26240,9 +26271,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/geolib": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.4.tgz",
|
||||
"integrity": "sha512-EicrlLLL3S42gE9/wde+11uiaYAaeSVDwCUIv2uMIoRBfNJCn8EsSI+6nS3r4TCKDO6+RQNM9ayLq2at+oZQWQ==",
|
||||
"version": "3.3.14",
|
||||
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
|
||||
"integrity": "sha512-uQ1772h3OjhWvL/HhSRZTMjBKIKoc4wFksLDqzqOkuG/2TgBbTwFamU0Disx3sNFk/BOweHyhKoVaYDuLIpsxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/geostyler": {
|
||||
@@ -33297,14 +33328,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
|
||||
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
|
||||
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.1",
|
||||
"@asamuzakjp/dom-selector": "^7.0.3",
|
||||
"@asamuzakjp/css-color": "^5.1.5",
|
||||
"@asamuzakjp/dom-selector": "^7.0.6",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
@@ -38903,6 +38934,17 @@
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/peek-stream": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz",
|
||||
"integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"duplexify": "^3.5.0",
|
||||
"through2": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -39960,6 +40002,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/process-streams": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/process-streams/-/process-streams-1.0.3.tgz",
|
||||
"integrity": "sha512-xkIaM5vYnyekB88WyET78YEqXiaJRy0xcvIdE22n+myhvBT7LlLmX6iAtq7jDvVH8CUx2rqQsd32JdRyJMV3NA==",
|
||||
"funding": [
|
||||
"https://www.paypal.com/donate/?hosted_button_id=GB656ZSAEQEXN",
|
||||
"https://de.liberapay.com/nils.knappmeier/"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplex-maker": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proggy": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/proggy/-/proggy-3.0.0.tgz",
|
||||
@@ -41383,15 +41438,6 @@
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
|
||||
"integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-intersection-observer": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.3.tgz",
|
||||
@@ -44267,17 +44313,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-zstd": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-zstd/-/simple-zstd-2.1.0.tgz",
|
||||
"integrity": "sha512-pYzmKWl167db0EHoczlsSpmyjvZ7OinXciHicDEtlHjSKZlo1hPz6tXSyOfS84QIrbWPYT0XW9tx24YtGdQ6cA==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-zstd/-/simple-zstd-1.4.2.tgz",
|
||||
"integrity": "sha512-kGYEvT33M5XfyQvvW4wxl3eKcWbdbCc1V7OZzuElnaXft0qbVzoIIXHXiCm3JCUki+MZKKmvjl8p2VGLJc5Y/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"is-zst": "^1.0.0",
|
||||
"tmp-promise": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"peek-stream": "^1.1.3",
|
||||
"process-streams": "^1.0.1",
|
||||
"through2": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-zstd/node_modules/through2": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
||||
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-stream": "3"
|
||||
}
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
@@ -45189,6 +45242,12 @@
|
||||
"readable-stream": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.21.1",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz",
|
||||
@@ -46204,20 +46263,12 @@
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp-promise": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
|
||||
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -52101,13 +52152,13 @@
|
||||
"version": "0.20.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "~9.2.9",
|
||||
"@deck.gl/aggregation-layers": "~9.2.11",
|
||||
"@deck.gl/core": "~9.2.5",
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@deck.gl/react": "~9.2.9",
|
||||
"@deck.gl/react": "~9.2.11",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.6",
|
||||
@@ -52456,8 +52507,7 @@
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "*",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "5.4.0"
|
||||
"react-dom": "^17.0.2"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-table": {
|
||||
@@ -52471,7 +52521,6 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"geolib": "^3.3.4",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.3.1",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.4.1",
|
||||
@@ -223,7 +223,7 @@
|
||||
"redux-undo": "^1.0.0-beta9-9-7",
|
||||
"rison": "^0.1.1",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"simple-zstd": "^2.1.0",
|
||||
"simple-zstd": "^1.4.2",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"urijs": "^1.19.8",
|
||||
@@ -270,8 +270,8 @@
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.21",
|
||||
"@swc/plugin-emotion": "^14.7.0",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@swc/plugin-emotion": "^14.8.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -301,7 +301,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"babel-jest": "^30.0.2",
|
||||
"babel-loader": "^10.1.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
@@ -340,7 +340,7 @@
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"lerna": "^9.0.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
|
||||
+4
@@ -21,7 +21,11 @@ import { styled, css } from '@apache-superset/core/theme';
|
||||
export const ControlSubSectionHeader = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
margin-top: ${theme.sizeUnit * 3}px;
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -319,6 +319,11 @@ export function AsyncAceEditor(
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Style bracket matching to blend with theme */
|
||||
.ace_editor .ace_bracket {
|
||||
border-color: ${token.colorPrimaryBorderHover} !important;
|
||||
}
|
||||
|
||||
/* Adjust cursor color */
|
||||
.ace_editor .ace_cursor {
|
||||
color: ${token.colorPrimaryText} !important;
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -270,6 +271,7 @@ const AntdIcons = {
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined,
|
||||
QuestionCircleOutlined,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import d3 from 'd3';
|
||||
import { extent as d3Extent } from 'd3-array';
|
||||
import {
|
||||
ValueFormatter,
|
||||
getNumberFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
CategoricalColorNamespace,
|
||||
@@ -60,7 +61,8 @@ interface CountryMapProps {
|
||||
height: number;
|
||||
country: string;
|
||||
linearColorScheme: string;
|
||||
numberFormat: string;
|
||||
numberFormat?: string; // left for backward compatibility
|
||||
formatter: ValueFormatter;
|
||||
colorScheme: string;
|
||||
sliceId: number;
|
||||
}
|
||||
@@ -74,13 +76,12 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
height,
|
||||
country,
|
||||
linearColorScheme,
|
||||
numberFormat,
|
||||
formatter,
|
||||
colorScheme,
|
||||
sliceId,
|
||||
} = props;
|
||||
|
||||
const container = element;
|
||||
const format = getNumberFormatter(numberFormat);
|
||||
const rawExtents = d3Extent(data, v => v.metric);
|
||||
const extents: [number, number] =
|
||||
rawExtents[0] != null && rawExtents[1] != null
|
||||
@@ -182,7 +183,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.style('top', `${position[1] + 30}px`)
|
||||
.style('left', `${position[0]}px`)
|
||||
.html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? format(result[0].metric) : ''}</div>`,
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
['currency_format'],
|
||||
['linear_color_scheme'],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -16,26 +16,48 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import { ChartProps, getValueFormatter } from '@superset-ui/core';
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, queriesData } = chartProps;
|
||||
const { width, height, formData, queriesData, datasource } = chartProps;
|
||||
const {
|
||||
linearColorScheme,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
selectCountry,
|
||||
colorScheme,
|
||||
sliceId,
|
||||
metric,
|
||||
} = formData;
|
||||
|
||||
const {
|
||||
currencyFormats = {},
|
||||
columnFormats = {},
|
||||
currencyCodeColumn,
|
||||
} = datasource;
|
||||
const { data, detected_currency: detectedCurrency } = queriesData[0];
|
||||
|
||||
const formatter = getValueFormatter(
|
||||
metric,
|
||||
currencyFormats,
|
||||
columnFormats,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
undefined, // key - not needed for single-metric charts
|
||||
data,
|
||||
currencyCodeColumn,
|
||||
detectedCurrency,
|
||||
);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
data: queriesData[0].data,
|
||||
country: selectCountry ? String(selectCountry).toLowerCase() : null,
|
||||
linearColorScheme,
|
||||
numberFormat,
|
||||
numberFormat, // left for backward compatibility
|
||||
colorScheme,
|
||||
sliceId,
|
||||
formatter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('CountryMap (legacy d3)', () => {
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
numberFormat=".2f"
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -115,6 +116,7 @@ describe('CountryMap (legacy d3)', () => {
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -144,6 +146,7 @@ describe('CountryMap (legacy d3)', () => {
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "~9.2.9",
|
||||
"@deck.gl/aggregation-layers": "~9.2.11",
|
||||
"@deck.gl/core": "~9.2.5",
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@deck.gl/react": "~9.2.9",
|
||||
"@deck.gl/react": "~9.2.11",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.6",
|
||||
|
||||
@@ -30,10 +30,12 @@ import { debounceFunc } from '../../consts';
|
||||
|
||||
interface StyleCustomControlProps {
|
||||
value: string;
|
||||
htmlSanitization: boolean;
|
||||
}
|
||||
|
||||
const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
|
||||
const theme = useTheme();
|
||||
const htmlSanitization = props.htmlSanitization ?? true;
|
||||
|
||||
const defaultValue = props?.value
|
||||
? undefined
|
||||
@@ -48,10 +50,16 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
|
||||
<ControlHeader>
|
||||
<div>
|
||||
{props.label}
|
||||
<InfoTooltip
|
||||
iconStyle={{ marginLeft: theme.sizeUnit }}
|
||||
tooltip={t('You need to configure HTML sanitization to use CSS')}
|
||||
/>
|
||||
{htmlSanitization && (
|
||||
<InfoTooltip
|
||||
iconStyle={{ marginLeft: theme.sizeUnit }}
|
||||
tooltip={t(
|
||||
'CSS styles may be removed by server-side HTML sanitization. ' +
|
||||
'If styles are not applying, ask your Superset administrator ' +
|
||||
'to adjust the HTML sanitization configuration.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ControlHeader>
|
||||
<CodeEditor
|
||||
@@ -79,8 +87,9 @@ export const styleControlSetItem: ControlSetItem = {
|
||||
valueKey: null,
|
||||
|
||||
validators: [],
|
||||
mapStateToProps: ({ controls }) => ({
|
||||
mapStateToProps: ({ controls, common }) => ({
|
||||
value: controls?.handlebars_template?.value,
|
||||
htmlSanitization: common?.conf?.HTML_SANITIZATION ?? true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "*",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "5.4.0"
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
|
||||
+11
-6
@@ -22,9 +22,11 @@ import { safeHtmlSpan } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FaSort } from 'react-icons/fa';
|
||||
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
|
||||
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
|
||||
import {
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
ColumnHeightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
ColorFormatters,
|
||||
getTextColorForBackground,
|
||||
@@ -855,7 +857,7 @@ export class TableRenderer extends Component<
|
||||
|
||||
if (activeSortColumn !== key) {
|
||||
return (
|
||||
<FaSort
|
||||
<ColumnHeightOutlined
|
||||
onClick={() =>
|
||||
this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
|
||||
}
|
||||
@@ -863,7 +865,8 @@ export class TableRenderer extends Component<
|
||||
);
|
||||
}
|
||||
|
||||
const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
|
||||
const SortIcon =
|
||||
sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined;
|
||||
return (
|
||||
<SortIcon
|
||||
onClick={() =>
|
||||
@@ -873,7 +876,9 @@ export class TableRenderer extends Component<
|
||||
);
|
||||
};
|
||||
const headerCellFormattedValue =
|
||||
dateFormatters?.[attrName]?.(convertToNumberIfNumeric(colKey[attrIdx])) ?? colKey[attrIdx];
|
||||
dateFormatters?.[attrName]?.(
|
||||
convertToNumberIfNumeric(colKey[attrIdx]),
|
||||
) ?? colKey[attrIdx];
|
||||
const { backgroundColor, color } = getCellColor(
|
||||
[attrName],
|
||||
headerCellFormattedValue,
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
|
||||
@@ -35,9 +35,11 @@ import {
|
||||
Row,
|
||||
} from 'react-table';
|
||||
import { extent as d3Extent, max as d3Max } from 'd3-array';
|
||||
import { FaSort } from 'react-icons/fa';
|
||||
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
|
||||
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
|
||||
import {
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
ColumnHeightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
DataRecord,
|
||||
@@ -221,9 +223,9 @@ function cellBackground({
|
||||
|
||||
function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
|
||||
const { isSorted, isSortedDesc } = column;
|
||||
let sortIcon = <FaSort />;
|
||||
let sortIcon = <ColumnHeightOutlined />;
|
||||
if (isSorted) {
|
||||
sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />;
|
||||
sortIcon = isSortedDesc ? <CaretDownOutlined /> : <CaretUpOutlined />;
|
||||
}
|
||||
return sortIcon;
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"d3-scale": "^4.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@types/lodash": "*",
|
||||
"@types/react": "*",
|
||||
"react": "^17.0.2"
|
||||
|
||||
@@ -74,13 +74,16 @@ interface ColumnElementProps {
|
||||
keys?: { type: ColumnKeyTypeType }[];
|
||||
type: string;
|
||||
};
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const NowrapDiv = styled.div`
|
||||
const ColumnType = styled.div`
|
||||
white-space: nowrap;
|
||||
color: ${({ theme }) => theme.colorTextDescription};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
`;
|
||||
|
||||
const ColumnElement = ({ column }: ColumnElementProps) => {
|
||||
const ColumnElement = ({ column, actions }: ColumnElementProps) => {
|
||||
let columnName: ReactNode = column.name;
|
||||
let icons;
|
||||
if (column.keys && column.keys.length > 0) {
|
||||
@@ -110,10 +113,9 @@ const ColumnElement = ({ column }: ColumnElementProps) => {
|
||||
<div data-test="col-name">
|
||||
{columnName}
|
||||
{icons}
|
||||
{actions}
|
||||
</div>
|
||||
<NowrapDiv className="text-muted">
|
||||
<small> {column.type}</small>
|
||||
</NowrapDiv>
|
||||
<ColumnType>{column.type}</ColumnType>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -257,6 +257,8 @@ test('returns column keywords among selected tables', async () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Both columns should be present since all cached table metadata
|
||||
// for this database is included in autocomplete
|
||||
await waitFor(() =>
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -268,31 +270,14 @@ test('returns column keywords among selected tables', async () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current).not.toContainEqual(
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: unexpectedColumn,
|
||||
value: unexpectedColumn,
|
||||
score: COLUMN_AUTOCOMPLETE_SCORE,
|
||||
meta: 'column',
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
storeWithSqlLab.dispatch(
|
||||
addTable(
|
||||
{ id: expectQueryEditorId } as any,
|
||||
unexpectedTable,
|
||||
expectCatalog,
|
||||
expectSchema,
|
||||
) as any,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: unexpectedColumn,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns long keywords with detail', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useSelector, useDispatch, shallowEqual, useStore } from 'react-redux';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
@@ -30,15 +30,10 @@ import {
|
||||
COLUMN_AUTOCOMPLETE_SCORE,
|
||||
SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
|
||||
} from 'src/SqlLab/constants';
|
||||
import {
|
||||
schemaEndpoints,
|
||||
tableEndpoints,
|
||||
skipToken,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { schemaEndpoints } from 'src/hooks/apiResources';
|
||||
import { api } from 'src/hooks/apiResources/queryApi';
|
||||
import { useDatabaseFunctionsQuery } from 'src/hooks/apiResources/databaseFunctions';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
|
||||
type Params = {
|
||||
queryEditorId: string | number;
|
||||
@@ -51,7 +46,6 @@ type Params = {
|
||||
const EMPTY_LIST = [] as typeof sqlKeywords;
|
||||
|
||||
const { useQueryState: useSchemasQueryState } = schemaEndpoints.schemas;
|
||||
const { useQueryState: useTablesQueryState } = tableEndpoints.tables;
|
||||
|
||||
const getHelperText = (value: string) =>
|
||||
value.length > 30 && {
|
||||
@@ -87,16 +81,6 @@ export function useKeywords(
|
||||
},
|
||||
{ skip: skipFetch || !dbId },
|
||||
);
|
||||
const { currentData: tableData } = useTablesQueryState(
|
||||
{
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
forceRefresh: false,
|
||||
},
|
||||
{ skip: skipFetch || !dbId || !schema },
|
||||
);
|
||||
|
||||
const { currentData: functionNames, isError } = useDatabaseFunctionsQuery(
|
||||
{ dbId },
|
||||
{ skip: skipFetch || !dbId },
|
||||
@@ -110,41 +94,64 @@ export function useKeywords(
|
||||
}
|
||||
}, [dispatch, isError]);
|
||||
|
||||
const tablesForColumnMetadata = useSelector<SqlLabRootState, string[]>(
|
||||
({ sqlLab }) =>
|
||||
skip
|
||||
? []
|
||||
: (sqlLab?.tables ?? [])
|
||||
.filter(table => table.queryEditorId === queryEditorId)
|
||||
.map(table => table.name),
|
||||
shallowEqual,
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
const apiState = store.getState()[api.reducerPath];
|
||||
|
||||
// Normalize catalog for comparison (null/undefined both mean "no catalog")
|
||||
const normalizedCatalog = catalog ?? null;
|
||||
|
||||
// Collect all table names from all cached table-list queries for this database/catalog.
|
||||
// This includes tables from any schema the user has expanded in the tree.
|
||||
const allCachedTables = useMemo(() => {
|
||||
if (skipFetch || !dbId || !apiState) return [];
|
||||
const tables: { value: string; label: string; schema: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queries = apiState.queries ?? {};
|
||||
for (const entry of Object.values(queries) as any[]) {
|
||||
const arg = entry?.originalArgs;
|
||||
if (
|
||||
arg?.dbId === dbId &&
|
||||
(arg?.catalog ?? null) === normalizedCatalog &&
|
||||
entry?.status === 'fulfilled' &&
|
||||
entry?.data?.options
|
||||
) {
|
||||
for (const table of entry.data.options) {
|
||||
const key = `${arg.schema}.${table.value}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
tables.push({
|
||||
value: table.value,
|
||||
label: table.label ?? table.value,
|
||||
schema: arg.schema,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}, [dbId, normalizedCatalog, apiState, skipFetch]);
|
||||
|
||||
// Collect column names from all cached table-metadata queries for this database/catalog.
|
||||
// This includes columns from any table the user has expanded in the tree.
|
||||
const allColumns = useMemo(() => {
|
||||
if (skipFetch || !dbId || !apiState) return [];
|
||||
const columns = new Set<string>();
|
||||
tablesForColumnMetadata.forEach(table => {
|
||||
tableEndpoints.tableMetadata
|
||||
.select(
|
||||
dbId && schema
|
||||
? {
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
table,
|
||||
}
|
||||
: skipToken,
|
||||
)({
|
||||
[api.reducerPath]: apiState,
|
||||
})
|
||||
.data?.columns?.forEach(({ name }) => {
|
||||
columns.add(name);
|
||||
});
|
||||
});
|
||||
const queries = apiState.queries ?? {};
|
||||
for (const entry of Object.values(queries) as any[]) {
|
||||
const arg = entry?.originalArgs;
|
||||
if (
|
||||
entry?.status === 'fulfilled' &&
|
||||
entry?.data?.columns &&
|
||||
arg?.dbId === dbId &&
|
||||
(arg?.catalog ?? null) === normalizedCatalog
|
||||
) {
|
||||
for (const col of entry.data.columns) {
|
||||
columns.add(col.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...columns];
|
||||
}, [dbId, catalog, schema, apiState, tablesForColumnMetadata]);
|
||||
}, [dbId, normalizedCatalog, apiState, skipFetch]);
|
||||
|
||||
const insertMatch = useEffectEvent((editor: Editor, data: any) => {
|
||||
if (data.meta === 'table') {
|
||||
@@ -153,7 +160,7 @@ export function useKeywords(
|
||||
{ id: String(queryEditorId), dbId: dbId as number, tabViewId },
|
||||
data.value,
|
||||
catalog ?? null,
|
||||
schema ?? '',
|
||||
data.schema ?? schema ?? '',
|
||||
false, // Don't auto-expand/switch tabs when adding via autocomplete
|
||||
),
|
||||
);
|
||||
@@ -187,9 +194,10 @@ export function useKeywords(
|
||||
|
||||
const tableKeywords = useMemo(
|
||||
() =>
|
||||
(tableData?.options ?? []).map(({ value, label }) => ({
|
||||
allCachedTables.map(({ value, label, schema: tableSchema }) => ({
|
||||
name: label,
|
||||
value,
|
||||
schema: tableSchema,
|
||||
score: TABLE_AUTOCOMPLETE_SCORE,
|
||||
meta: 'table',
|
||||
completer: {
|
||||
@@ -197,7 +205,7 @@ export function useKeywords(
|
||||
},
|
||||
...getHelperText(value),
|
||||
})),
|
||||
[tableData?.options, insertMatch],
|
||||
[allCachedTables, insertMatch],
|
||||
);
|
||||
|
||||
const columnKeywords = useMemo(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ModalTrigger } from '@superset-ui/core/components';
|
||||
import CodeSyntaxHighlighter from '@superset-ui/core/components/CodeSyntaxHighlighter';
|
||||
@@ -40,6 +41,12 @@ interface TriggerNodeProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
const Title = styled.h4`
|
||||
font-size: ${({ theme }) => theme.fontSizeLG}px;
|
||||
margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
|
||||
const ssql = sql || '';
|
||||
let lines = ssql.split('\n');
|
||||
@@ -63,14 +70,32 @@ function TriggerNode({ shrink, sql, maxLines, maxWidth }: TriggerNodeProps) {
|
||||
}
|
||||
|
||||
function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
|
||||
const theme = useTheme();
|
||||
const codeBlockStyle = {
|
||||
border: 1,
|
||||
borderColor: theme.colorBorder,
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.colorBgLayout,
|
||||
fontSize: theme.fontSize * 0.9,
|
||||
padding: theme.sizeUnit * 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{t('Source SQL')}</h4>
|
||||
<CodeSyntaxHighlighter language="sql">{sql}</CodeSyntaxHighlighter>
|
||||
<div
|
||||
css={css`
|
||||
margin: -${theme.sizeUnit * 6}px;
|
||||
`}
|
||||
>
|
||||
<Title>{t('Source SQL')}</Title>
|
||||
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
|
||||
{sql}
|
||||
</CodeSyntaxHighlighter>
|
||||
{rawSql && rawSql !== sql && (
|
||||
<div>
|
||||
<h4>{t('Executed SQL')}</h4>
|
||||
<CodeSyntaxHighlighter language="sql">{rawSql}</CodeSyntaxHighlighter>
|
||||
<Title>{t('Executed SQL')}</Title>
|
||||
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
|
||||
{rawSql}
|
||||
</CodeSyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ const QueryLimitSelect = ({
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
showMarginRight={false}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ const SaveDatasetActionButton = ({
|
||||
}: SaveDatasetActionButtonProps) => (
|
||||
<>
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => setShowSave(true)}
|
||||
icon={<Icons.SaveOutlined />}
|
||||
@@ -40,7 +40,7 @@ const SaveDatasetActionButton = ({
|
||||
/>
|
||||
{onSaveAsExplore && (
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => onSaveAsExplore?.()}
|
||||
icon={<Icons.TableOutlined />}
|
||||
|
||||
@@ -233,7 +233,7 @@ const SaveQuery = ({
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle={isSaved ? undefined : 'primary'}
|
||||
buttonStyle={isSaved ? 'secondary' : 'primary'}
|
||||
onClick={onSaveWrapper}
|
||||
cta
|
||||
>
|
||||
|
||||
@@ -71,7 +71,7 @@ const ShareSqlLabQuery = ({
|
||||
const tooltip = t('Copy query link to your clipboard');
|
||||
return (
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
tooltip={tooltip}
|
||||
css={css`
|
||||
|
||||
@@ -201,7 +201,7 @@ test('display no compatible schema found when schema api throws errors', async (
|
||||
).toBeGreaterThanOrEqual(1),
|
||||
);
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema',
|
||||
});
|
||||
userEvent.click(select);
|
||||
expect(
|
||||
|
||||
@@ -134,9 +134,9 @@ test('filters schemas when searching', async () => {
|
||||
expect(screen.getByText('public')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify selected schemas are initially visible
|
||||
expect(screen.queryByText('test_schema')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('information_schema')).not.toBeInTheDocument();
|
||||
// All schemas are visible (no longer filtered to selected schema)
|
||||
expect(screen.getByText('test_schema')).toBeInTheDocument();
|
||||
expect(screen.getByText('information_schema')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Enter a part of the object name',
|
||||
|
||||
@@ -16,21 +16,32 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import type { NodeRendererProps } from 'react-arborist';
|
||||
import { Icons, Tooltip, Typography } from '@superset-ui/core/components';
|
||||
import { Icons, Typography } from '@superset-ui/core/components';
|
||||
import RefreshLabel from '@superset-ui/core/components/RefreshLabel';
|
||||
import ColumnElement from 'src/SqlLab/components/ColumnElement';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
import type { TreeNodeData, FetchLazyTablesParams } from './types';
|
||||
import { ActionButton } from '@superset-ui/core/components/ActionButton';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import type { TreeNodeData } from './types';
|
||||
|
||||
const StyledColumnNode = styled.div`
|
||||
& > .ant-flex {
|
||||
flex: 1;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 1.5}px;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.col-copy-action {
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
margin-left: ${({ theme }) => theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
&:hover .col-copy-action {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const getOpacity = (disableCheckbox: boolean | undefined) =>
|
||||
@@ -67,12 +78,19 @@ export interface TreeNodeRendererProps extends NodeRendererProps<TreeNodeData> {
|
||||
loadingNodes: Record<string, boolean>;
|
||||
searchTerm: string;
|
||||
catalog: string | null | undefined;
|
||||
fetchLazyTables: (params: FetchLazyTablesParams) => void;
|
||||
pinnedTableKeys: Set<string>;
|
||||
selectStarMap: Record<string, string>;
|
||||
handleRefreshTables: (params: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => void;
|
||||
handlePinTable: (
|
||||
tableName: string,
|
||||
schemaName: string,
|
||||
catalogName: string | null,
|
||||
) => void;
|
||||
handleUnpinTable: (tableName: string, schemaName: string) => void;
|
||||
}
|
||||
|
||||
const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
@@ -82,9 +100,13 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
loadingNodes,
|
||||
searchTerm,
|
||||
catalog,
|
||||
fetchLazyTables,
|
||||
pinnedTableKeys,
|
||||
selectStarMap,
|
||||
handleRefreshTables,
|
||||
handlePinTable,
|
||||
handleUnpinTable,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { data } = node;
|
||||
const parts = data.id.split(':');
|
||||
const [identifier, _dbId, schema, tableName] = parts;
|
||||
@@ -109,8 +131,9 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
|
||||
if (identifier === 'table') {
|
||||
const TableTypeIcon =
|
||||
data.tableType === 'view' ? Icons.EyeOutlined : Icons.TableOutlined;
|
||||
// Show loading icon with table type icon when loading
|
||||
data.tableType === 'view'
|
||||
? Icons.FunctionOutlined
|
||||
: Icons.TableOutlined;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -119,15 +142,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
</>
|
||||
);
|
||||
}
|
||||
const ExpandIcon = isManuallyOpen
|
||||
? Icons.MinusSquareOutlined
|
||||
: Icons.PlusSquareOutlined;
|
||||
return (
|
||||
<>
|
||||
<ExpandIcon iconSize="l" />
|
||||
<TableTypeIcon iconSize="l" />
|
||||
</>
|
||||
);
|
||||
return <TableTypeIcon iconSize="l" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -162,7 +177,24 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
data-selected={node.isSelected}
|
||||
onClick={() => node.select()}
|
||||
>
|
||||
<ColumnElement column={data.columnData} />
|
||||
<ColumnElement
|
||||
column={data.columnData}
|
||||
actions={
|
||||
<span
|
||||
className="col-copy-action"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ActionButton
|
||||
label={`copy-col-${data.name}`}
|
||||
tooltip={t('Copy column name')}
|
||||
icon={<Icons.CopyOutlined iconSize="m" />}
|
||||
onClick={() =>
|
||||
copyTextToClipboard(() => Promise.resolve(data.name))
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</StyledColumnNode>
|
||||
);
|
||||
}
|
||||
@@ -205,38 +237,94 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
<RefreshLabel
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
fetchLazyTables({
|
||||
dbId: _dbId,
|
||||
handleRefreshTables({
|
||||
dbId: Number(_dbId),
|
||||
catalog,
|
||||
schema,
|
||||
forceRefresh: true,
|
||||
});
|
||||
}}
|
||||
tooltipContent={t('Force refresh table list')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{identifier === 'table' && (
|
||||
<div
|
||||
className="side-action-container"
|
||||
role="menu"
|
||||
css={css`
|
||||
position: inherit;
|
||||
`}
|
||||
>
|
||||
<IconButton
|
||||
icon={
|
||||
<Tooltip title={t('Pin to the result panel')}>
|
||||
<Icons.PushpinOutlined iconSize="xl" />
|
||||
</Tooltip>
|
||||
}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handlePinTable(tableName, schema, catalog ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{identifier === 'table' &&
|
||||
(() => {
|
||||
const nodeDbId = Number(_dbId);
|
||||
const tableKey = `${nodeDbId}:${schema}:${tableName}`;
|
||||
const isPinned = pinnedTableKeys.has(tableKey);
|
||||
const selectStar = selectStarMap[tableKey];
|
||||
return (
|
||||
<div
|
||||
className="side-action-container"
|
||||
role="menu"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{isPinned && (
|
||||
<div className="action-static">
|
||||
<ActionButton
|
||||
label={`pinned-${schema}-${tableName}`}
|
||||
icon={
|
||||
<Icons.PushpinFilled
|
||||
iconSize="m"
|
||||
css={css`
|
||||
color: ${theme.colorTextDescription};
|
||||
`}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleUnpinTable(tableName, schema)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="action-hover">
|
||||
{selectStar && (
|
||||
<ActionButton
|
||||
label={`copy-select-${schema}-${tableName}`}
|
||||
tooltip={t('Copy SELECT statement to the clipboard')}
|
||||
icon={<Icons.CopyOutlined iconSize="m" />}
|
||||
onClick={() =>
|
||||
copyTextToClipboard(() => Promise.resolve(selectStar))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label={
|
||||
isPinned
|
||||
? `unpin-${schema}-${tableName}`
|
||||
: `pin-${schema}-${tableName}`
|
||||
}
|
||||
tooltip={
|
||||
isPinned
|
||||
? t('Unpin from the result panel')
|
||||
: t('Pin to the result panel')
|
||||
}
|
||||
icon={
|
||||
isPinned ? (
|
||||
<Icons.PushpinFilled iconSize="m" />
|
||||
) : (
|
||||
<Icons.PushpinOutlined iconSize="m" />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
isPinned
|
||||
? handleUnpinTable(tableName, schema)
|
||||
: handlePinTable(tableName, schema, catalog ?? null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
label={`toggle-${schema}-${tableName}`}
|
||||
icon={
|
||||
isManuallyOpen ? (
|
||||
<Icons.UpOutlined iconSize="m" />
|
||||
) : (
|
||||
<Icons.DownOutlined iconSize="m" />
|
||||
)
|
||||
}
|
||||
onClick={() => node.toggle()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from '@superset-ui/core/components';
|
||||
import type { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import { addTable } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addTable, removeTables } from 'src/SqlLab/actions/sqlLab';
|
||||
import PanelToolbar from 'src/components/PanelToolbar';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import TreeNodeRenderer from './TreeNodeRenderer';
|
||||
@@ -64,16 +64,24 @@ const StyledTreeContainer = styled.div`
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.colorBgTextHover};
|
||||
|
||||
.side-action-container {
|
||||
opacity: 1;
|
||||
.action-static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background-color: ${({ theme }) => theme.colorBgTextActive};
|
||||
|
||||
.side-action-container {
|
||||
opacity: 1;
|
||||
.action-static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,12 +106,21 @@ const StyledTreeContainer = styled.div`
|
||||
}
|
||||
|
||||
.side-action-container {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.sizeUnit * 1.5}px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: ${({ theme }) => theme.zIndexPopupBase};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.action-static {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 0.5}px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -119,19 +136,20 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
);
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'dbId',
|
||||
'schema',
|
||||
'catalog',
|
||||
'tabViewId',
|
||||
]);
|
||||
const { dbId, catalog, schema: selectedSchema } = queryEditor;
|
||||
const { dbId, catalog } = queryEditor;
|
||||
const editorId = queryEditor.tabViewId ?? queryEditor.id;
|
||||
const pinnedTables = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
tables.map(({ queryEditorId, dbId, schema, name, persistData }) => [
|
||||
queryEditor.id === queryEditorId ? `${dbId}:${schema}:${name}` : '',
|
||||
editorId === queryEditorId ? `${dbId}:${schema}:${name}` : '',
|
||||
persistData,
|
||||
]),
|
||||
),
|
||||
[tables, queryEditor.id],
|
||||
[tables, editorId],
|
||||
);
|
||||
|
||||
// Tree data hook - manages schema/table/column data fetching and tree structure
|
||||
@@ -140,21 +158,47 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
isFetching,
|
||||
refetch,
|
||||
loadingNodes,
|
||||
selectStarMap,
|
||||
handleToggle,
|
||||
fetchLazyTables,
|
||||
handleRefreshTables,
|
||||
errorPayload,
|
||||
} = useTreeData({
|
||||
dbId,
|
||||
catalog,
|
||||
selectedSchema,
|
||||
pinnedTables,
|
||||
});
|
||||
|
||||
const pinnedTableKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
tables
|
||||
.filter(({ queryEditorId: qeId }) => editorId === qeId)
|
||||
.map(({ dbId, schema, name }) => `${dbId}:${schema}:${name}`),
|
||||
),
|
||||
[tables, editorId],
|
||||
);
|
||||
|
||||
const handlePinTable = useCallback(
|
||||
(tableName: string, schemaName: string, catalogName: string | null) =>
|
||||
dispatch(addTable(queryEditor, tableName, catalogName, schemaName)),
|
||||
[dispatch, queryEditor],
|
||||
);
|
||||
|
||||
const handleUnpinTable = useCallback(
|
||||
(tableName: string, schemaName: string) => {
|
||||
const table = tables.find(
|
||||
t =>
|
||||
t.queryEditorId === editorId &&
|
||||
t.dbId === dbId &&
|
||||
t.schema === schemaName &&
|
||||
t.name === tableName,
|
||||
);
|
||||
if (table) {
|
||||
dispatch(removeTables([table]));
|
||||
}
|
||||
},
|
||||
[dispatch, tables, editorId, dbId],
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const handleSearchChange = useCallback(
|
||||
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
|
||||
@@ -238,14 +282,20 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
loadingNodes={loadingNodes}
|
||||
searchTerm={searchTerm}
|
||||
catalog={catalog}
|
||||
fetchLazyTables={fetchLazyTables}
|
||||
pinnedTableKeys={pinnedTableKeys}
|
||||
selectStarMap={selectStarMap}
|
||||
handleRefreshTables={handleRefreshTables}
|
||||
handlePinTable={handlePinTable}
|
||||
handleUnpinTable={handleUnpinTable}
|
||||
/>
|
||||
),
|
||||
[
|
||||
catalog,
|
||||
fetchLazyTables,
|
||||
pinnedTableKeys,
|
||||
selectStarMap,
|
||||
handleRefreshTables,
|
||||
handlePinTable,
|
||||
handleUnpinTable,
|
||||
loadingNodes,
|
||||
manuallyOpenedNodes,
|
||||
searchTerm,
|
||||
|
||||
@@ -93,7 +93,6 @@ function treeDataReducer(
|
||||
interface UseTreeDataParams {
|
||||
dbId: number | undefined;
|
||||
catalog: string | null | undefined;
|
||||
selectedSchema: string | undefined;
|
||||
pinnedTables: Record<string, TableMetaData | undefined>;
|
||||
}
|
||||
|
||||
@@ -102,8 +101,13 @@ interface UseTreeDataResult {
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
loadingNodes: Record<string, boolean>;
|
||||
selectStarMap: Record<string, string>;
|
||||
handleToggle: (id: string, isOpen: boolean) => Promise<void>;
|
||||
fetchLazyTables: ReturnType<typeof useLazyTablesQuery>[0];
|
||||
handleRefreshTables: (params: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => void;
|
||||
errorPayload: SupersetError | null;
|
||||
}
|
||||
|
||||
@@ -116,7 +120,6 @@ const createEmptyNode = (parentId: string): TreeNodeData => ({
|
||||
const useTreeData = ({
|
||||
dbId,
|
||||
catalog,
|
||||
selectedSchema,
|
||||
pinnedTables,
|
||||
}: UseTreeDataParams): UseTreeDataResult => {
|
||||
// Schema data from API
|
||||
@@ -247,14 +250,48 @@ const useTreeData = ({
|
||||
],
|
||||
);
|
||||
|
||||
// Force-refresh the table list for a schema and update the tree
|
||||
const handleRefreshTables = useCallback(
|
||||
({
|
||||
dbId: refreshDbId,
|
||||
catalog: refreshCatalog,
|
||||
schema,
|
||||
}: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => {
|
||||
const schemaKey = `${refreshDbId}:${schema}`;
|
||||
const nodeId = `schema:${refreshDbId}:${schema}`;
|
||||
|
||||
dispatch({ type: 'SET_LOADING_NODE', nodeId, loading: true });
|
||||
|
||||
fetchLazyTables({
|
||||
dbId: refreshDbId,
|
||||
catalog: refreshCatalog,
|
||||
schema,
|
||||
forceRefresh: true,
|
||||
})
|
||||
.unwrap()
|
||||
.then(data => {
|
||||
dispatch({ type: 'SET_TABLE_DATA', key: schemaKey, data });
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
errorPayload: error?.errors?.[0] ?? null,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
dispatch({ type: 'SET_LOADING_NODE', nodeId, loading: false });
|
||||
});
|
||||
},
|
||||
[fetchLazyTables],
|
||||
);
|
||||
|
||||
// Build tree data
|
||||
const treeData = useMemo((): TreeNodeData[] => {
|
||||
// Filter schemas if a schema is selected, otherwise show all
|
||||
const filteredSchemaData = selectedSchema
|
||||
? schemaData?.filter(schema => schema.value === selectedSchema)
|
||||
: schemaData;
|
||||
|
||||
const data = filteredSchemaData?.map(schema => {
|
||||
const data = schemaData?.map(schema => {
|
||||
const schemaKey = `${dbId}:${schema.value}`;
|
||||
const schemaId = `schema:${dbId}:${schema.value}`;
|
||||
const tablesData = tableData?.[schemaKey];
|
||||
@@ -316,22 +353,31 @@ const useTreeData = ({
|
||||
});
|
||||
|
||||
return data ?? [];
|
||||
}, [
|
||||
dbId,
|
||||
schemaData,
|
||||
tableData,
|
||||
tableSchemaData,
|
||||
pinnedTables,
|
||||
selectedSchema,
|
||||
]);
|
||||
}, [dbId, schemaData, tableData, tableSchemaData, pinnedTables]);
|
||||
|
||||
// Map of tableKey -> selectStar SQL from table metadata
|
||||
const selectStarMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
const addEntry = (key: string, meta: TableMetaData | undefined) => {
|
||||
if (meta?.selectStar) {
|
||||
map[key] = meta.selectStar;
|
||||
}
|
||||
};
|
||||
Object.entries(tableSchemaData).forEach(([key, meta]) =>
|
||||
addEntry(key, meta),
|
||||
);
|
||||
Object.entries(pinnedTables).forEach(([key, meta]) => addEntry(key, meta));
|
||||
return map;
|
||||
}, [tableSchemaData, pinnedTables]);
|
||||
|
||||
return {
|
||||
treeData,
|
||||
isFetching,
|
||||
refetch,
|
||||
loadingNodes,
|
||||
selectStarMap,
|
||||
handleToggle,
|
||||
fetchLazyTables,
|
||||
handleRefreshTables,
|
||||
errorPayload,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,9 +29,12 @@ import {
|
||||
} from 'spec/helpers/testing-library';
|
||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
||||
import DrillByModal, { DrillByModalProps } from './DrillByModal';
|
||||
|
||||
setupAGGridModules();
|
||||
|
||||
// Mock the isEmbedded function
|
||||
jest.mock('src/dashboard/util/isEmbedded', () => ({
|
||||
isEmbedded: jest.fn(() => false),
|
||||
@@ -406,16 +409,9 @@ describe('Table view with pagination', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that pagination is rendered (there's also a breadcrumb list)
|
||||
const lists = screen.getAllByRole('list');
|
||||
const paginationList = lists.find(list =>
|
||||
list.className?.includes('pagination'),
|
||||
);
|
||||
expect(paginationList).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle pagination in table view', async () => {
|
||||
test('should render data in table view', async () => {
|
||||
await renderModal({
|
||||
column: { column_name: 'state', verbose_name: null },
|
||||
drillByConfig: {
|
||||
@@ -432,19 +428,9 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that first page data is shown
|
||||
expect(screen.getByText('State0')).toBeInTheDocument();
|
||||
|
||||
// Check pagination controls exist
|
||||
const nextPageButton = screen.getByTitle('Next Page');
|
||||
expect(nextPageButton).toBeInTheDocument();
|
||||
|
||||
// Click next page
|
||||
userEvent.click(nextPageButton);
|
||||
|
||||
// Verify page changed (State0 should not be visible on page 2)
|
||||
// Check that data is rendered in the grid
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('State0')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('State0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,11 +528,12 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show empty state
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
// ag-grid shows its own empty overlay when there are no rows
|
||||
const tableContainer = screen.getByTestId('drill-by-results-table');
|
||||
expect(tableContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle sorting in table view', async () => {
|
||||
test('should render grid in table view', async () => {
|
||||
await renderModal({
|
||||
column: { column_name: 'state', verbose_name: null },
|
||||
drillByConfig: {
|
||||
@@ -563,16 +550,7 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find sortable column header
|
||||
const sortableHeaders = screen.getAllByTestId('sort-header');
|
||||
expect(sortableHeaders.length).toBeGreaterThan(0);
|
||||
|
||||
// Click to sort
|
||||
userEvent.click(sortableHeaders[0]);
|
||||
|
||||
// Table should still be rendered without crashes
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,25 +25,12 @@ import {
|
||||
within,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { useResultsTableView } from './useResultsTableView';
|
||||
|
||||
const capturedProps: any[] = [];
|
||||
|
||||
jest.mock(
|
||||
'src/explore/components/DataTablesPane/components/SingleQueryResultPane',
|
||||
() => {
|
||||
const actual = jest.requireActual(
|
||||
'src/explore/components/DataTablesPane/components/SingleQueryResultPane',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
SingleQueryResultPane: (props: any) => {
|
||||
capturedProps.push(props);
|
||||
return actual.SingleQueryResultPane(props);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
const MOCK_CHART_DATA_RESULT = [
|
||||
{
|
||||
@@ -92,9 +79,9 @@ test('Displays results table for 1 query', () => {
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('sort-header')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('table-row')).toHaveLength(4);
|
||||
expect(screen.getByText('name')).toBeInTheDocument();
|
||||
expect(screen.getByText('sum__num')).toBeInTheDocument();
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Displays results for 2 queries', async () => {
|
||||
@@ -102,60 +89,18 @@ test('Displays results for 2 queries', async () => {
|
||||
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
const getActiveTabElement = () =>
|
||||
document.querySelector('.ant-tabs-tabpane-active') as HTMLElement;
|
||||
|
||||
const tablistElement = screen.getByRole('tablist');
|
||||
expect(tablistElement).toBeInTheDocument();
|
||||
expect(within(tablistElement).getByText('Results 1')).toBeInTheDocument();
|
||||
expect(within(tablistElement).getByText('Results 2')).toBeInTheDocument();
|
||||
|
||||
expect(within(getActiveTabElement()).getByRole('table')).toBeInTheDocument();
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('sort-header'),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('table-row'),
|
||||
).toHaveLength(4);
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByText('Results 2'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('sort-header'),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('table-row'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('passes isPaginationSticky={false} to SingleQueryResultPane for single query', () => {
|
||||
capturedProps.length = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table', true),
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
|
||||
expect(capturedProps.length).toBeGreaterThan(0);
|
||||
capturedProps.forEach(props => {
|
||||
expect(props).toMatchObject({
|
||||
isPaginationSticky: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('passes isPaginationSticky={false} to SingleQueryResultPane for multiple queries', () => {
|
||||
capturedProps.length = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
|
||||
expect(capturedProps.length).toBeGreaterThanOrEqual(2);
|
||||
capturedProps.forEach(props => {
|
||||
expect(props).toMatchObject({
|
||||
isPaginationSticky: false,
|
||||
});
|
||||
expect(screen.getByText('gender')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('boy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -22,13 +22,12 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { SingleQueryResultPane } from 'src/explore/components/DataTablesPane/components/SingleQueryResultPane';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
|
||||
const DATA_SIZE = 15;
|
||||
|
||||
const PaginationContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
& .pagination-container {
|
||||
bottom: ${-theme.sizeUnit * 4}px;
|
||||
}
|
||||
const ResultContainer = styled.div`
|
||||
${() => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -42,19 +41,17 @@ export const useResultsTableView = (
|
||||
}
|
||||
if (chartDataResult.length === 1) {
|
||||
return (
|
||||
<PaginationContainer data-test="drill-by-results-table">
|
||||
<ResultContainer data-test="drill-by-results-table">
|
||||
<SingleQueryResultPane
|
||||
colnames={chartDataResult[0].colnames}
|
||||
coltypes={chartDataResult[0].coltypes}
|
||||
rowcount={chartDataResult[0].sql_rowcount}
|
||||
data={chartDataResult[0].data}
|
||||
dataSize={DATA_SIZE}
|
||||
datasourceId={datasourceId}
|
||||
isVisible
|
||||
canDownload={canDownload}
|
||||
isPaginationSticky={false}
|
||||
/>
|
||||
</PaginationContainer>
|
||||
</ResultContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -64,19 +61,17 @@ export const useResultsTableView = (
|
||||
key: `result-tab-${index}`,
|
||||
label: t('Results %s', index + 1),
|
||||
children: (
|
||||
<PaginationContainer>
|
||||
<ResultContainer>
|
||||
<SingleQueryResultPane
|
||||
colnames={res.colnames}
|
||||
coltypes={res.coltypes}
|
||||
data={res.data}
|
||||
rowcount={res.sql_rowcount}
|
||||
dataSize={DATA_SIZE}
|
||||
datasourceId={datasourceId}
|
||||
isVisible
|
||||
canDownload={canDownload}
|
||||
isPaginationSticky={false}
|
||||
/>
|
||||
</PaginationContainer>
|
||||
</ResultContainer>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -214,7 +214,7 @@ test('Refresh should work', async () => {
|
||||
expect(fetchMock.callHistory.calls(schemaApiRoute).length).toBe(0);
|
||||
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
name: 'Select schema: public',
|
||||
});
|
||||
|
||||
await userEvent.click(select);
|
||||
@@ -331,7 +331,7 @@ test('Should schema select display options', async () => {
|
||||
const props = createProps();
|
||||
render(<DatabaseSelector {...props} />, { useRedux: true, store });
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
name: 'Select schema: public',
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
await userEvent.click(select);
|
||||
@@ -379,7 +379,7 @@ test('Sends the correct schema when changing the schema', async () => {
|
||||
rerender(<DatabaseSelector {...props} />);
|
||||
expect(props.onSchemaChange).toHaveBeenCalledTimes(0);
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
name: 'Select schema: public',
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
await userEvent.click(select);
|
||||
|
||||
@@ -515,17 +515,12 @@ export function DatabaseSelector({
|
||||
|
||||
function renderSchemaSelect() {
|
||||
if (sqlLabMode) {
|
||||
return renderSelectRow(
|
||||
t('Select schema or type to search schemas'),
|
||||
null,
|
||||
null,
|
||||
{
|
||||
displayValue: currentSchema?.label,
|
||||
disabled: !currentDb || readOnly,
|
||||
loading: loadingSchemas,
|
||||
icon: <Icons.RightOutlined />,
|
||||
},
|
||||
);
|
||||
return renderSelectRow(t('Select schema'), null, null, {
|
||||
displayValue: currentSchema?.label,
|
||||
disabled: !currentDb || readOnly,
|
||||
loading: loadingSchemas,
|
||||
icon: <Icons.RightOutlined />,
|
||||
});
|
||||
}
|
||||
const refreshIcon = !readOnly && (
|
||||
<RefreshLabel
|
||||
@@ -539,13 +534,13 @@ export function DatabaseSelector({
|
||||
{renderSelectRow(
|
||||
t('Schema'),
|
||||
<Select
|
||||
ariaLabel={t('Select schema or type to search schemas')}
|
||||
ariaLabel={t('Select schema')}
|
||||
disabled={!currentDb || readOnly}
|
||||
labelInValue
|
||||
loading={loadingSchemas}
|
||||
name="select-schema"
|
||||
notFoundContent={t('No compatible schema found')}
|
||||
placeholder={t('Select schema or type to search schemas')}
|
||||
placeholder={t('Select schema')}
|
||||
onChange={item => changeSchema(item as SchemaOption)}
|
||||
options={schemaOptions}
|
||||
showSearch
|
||||
|
||||
@@ -99,6 +99,7 @@ export function DatabaseErrorMessage({
|
||||
<ErrorAlert
|
||||
errorType={t('%s Error', extra?.engine_name || t('DB engine'))}
|
||||
message={alertMessage}
|
||||
messagePre
|
||||
description={alertDescription}
|
||||
type={level}
|
||||
descriptionDetails={body}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
|
||||
description,
|
||||
descriptionDetails,
|
||||
descriptionDetailsCollapsed = true,
|
||||
messagePre = false,
|
||||
descriptionPre = true,
|
||||
compact = false,
|
||||
children,
|
||||
@@ -69,13 +70,20 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
|
||||
);
|
||||
};
|
||||
const preStyle = {
|
||||
whiteSpace: 'pre-wrap',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
fontFamily: theme.fontFamilyCode,
|
||||
margin: `${theme.sizeUnit}px 0`,
|
||||
};
|
||||
const renderDescription = () => (
|
||||
<div>
|
||||
{message && <div>{message}</div>}
|
||||
{message &&
|
||||
(messagePre ? (
|
||||
<Typography.Paragraph style={preStyle}>
|
||||
{message}
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<div>{message}</div>
|
||||
))}
|
||||
{description && (
|
||||
<Typography.Paragraph
|
||||
style={descriptionPre ? preStyle : {}}
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ErrorAlertProps {
|
||||
description?: React.ReactNode; // Text shown under the first line, not collapsible
|
||||
descriptionDetails?: React.ReactNode | string; // Text shown under the first line, collapsible
|
||||
descriptionDetailsCollapsed?: boolean; // Hides the collapsible section unless "Show more" is clicked, default true
|
||||
messagePre?: boolean; // Uses pre-style on the message, default false
|
||||
descriptionPre?: boolean; // Uses pre-style to break lines, default true
|
||||
compact?: boolean; // Shows the error icon with tooltip and modal, default false
|
||||
children?: React.ReactNode; // Additional content to show in the modal
|
||||
|
||||
@@ -62,7 +62,7 @@ const PanelToolbar = ({
|
||||
buttonSize="small"
|
||||
aria-label={command?.title}
|
||||
variant="text"
|
||||
color="primary"
|
||||
color="default"
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -140,7 +140,7 @@ const PanelToolbar = ({
|
||||
>
|
||||
<Button
|
||||
showMarginRight={false}
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
css={css`
|
||||
padding: 8px;
|
||||
|
||||
@@ -93,7 +93,7 @@ test('renders with default props', async () => {
|
||||
name: 'Select database or type to search databases',
|
||||
});
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: test_schema',
|
||||
name: 'Select schema: test_schema',
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: 'Select table or type to search tables',
|
||||
|
||||
@@ -288,8 +288,15 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
<Bar className={cx({ open: filtersOpen })} width={width}>
|
||||
<Header toggleFiltersBar={toggleFiltersBar} />
|
||||
{!isInitialized ? (
|
||||
<div css={{ height }}>
|
||||
<Loading size="s" muted />
|
||||
<div
|
||||
css={{
|
||||
height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Loading position="inline-centered" size="s" muted />
|
||||
</div>
|
||||
) : (
|
||||
<div css={tabPaneStyle} onScroll={onScroll}>
|
||||
|
||||
+2
-1
@@ -654,7 +654,8 @@ test('reorders filters via keyboard (Space, ArrowDown, Space)', async () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('updates sidebar title when filter name changes', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests -- flaky timeout, see https://github.com/apache/superset/pull/39181
|
||||
test.skip('updates sidebar title when filter name changes', async () => {
|
||||
const nativeFilterConfig = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
|
||||
@@ -648,6 +648,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
</span>
|
||||
);
|
||||
|
||||
let isInSubSection = false;
|
||||
const PanelChildren = (
|
||||
<>
|
||||
<StashFormDataContainer
|
||||
@@ -665,8 +666,19 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
.filter(Boolean)}
|
||||
/>
|
||||
{isVisible && (
|
||||
<>
|
||||
<div style={{ paddingLeft: theme.sizeUnit * 2 }}>
|
||||
{section.controlSetRows.map((controlSets, i) => {
|
||||
// Detect sub-section header rows (React elements with no name prop)
|
||||
const isSubSectionHeaderRow = controlSets.some(
|
||||
item =>
|
||||
isValidElement(item) &&
|
||||
!(item as React.ReactElement<Record<string, unknown>>).props
|
||||
?.name,
|
||||
);
|
||||
if (isSubSectionHeaderRow) {
|
||||
isInSubSection = true;
|
||||
}
|
||||
|
||||
const renderedControls = controlSets
|
||||
.map(controlItem => {
|
||||
if (!controlItem) {
|
||||
@@ -715,14 +727,23 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
if (renderedControls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
// Indent controls within sub-sections for visual hierarchy
|
||||
const paddingLeft =
|
||||
isInSubSection && !isSubSectionHeaderRow
|
||||
? theme.sizeUnit * 3
|
||||
: 0;
|
||||
return paddingLeft ? (
|
||||
<div key={`controlsetrow-${i}`} style={{ paddingLeft }}>
|
||||
<ControlRow controls={renderedControls} />
|
||||
</div>
|
||||
) : (
|
||||
<ControlRow
|
||||
key={`controlsetrow-${i}`}
|
||||
controls={renderedControls}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -206,6 +206,7 @@ export const DataTablesPane = ({
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
|
||||
+28
-1
@@ -20,6 +20,7 @@ import { styled, css } from '@apache-superset/core/theme';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { useMemo } from 'react';
|
||||
import { zip } from 'lodash';
|
||||
import { Select } from 'antd';
|
||||
import {
|
||||
CopyToClipboardButton,
|
||||
FilterInput,
|
||||
@@ -29,10 +30,19 @@ import { getTimeColumns } from 'src/explore/components/DataTableControl/utils';
|
||||
import RowCountLabel from 'src/components/RowCountLabel';
|
||||
import { TableControlsProps } from '../types';
|
||||
|
||||
export const ROW_LIMIT_OPTIONS = [
|
||||
{ value: 100, label: '100 rows' },
|
||||
{ value: 500, label: '500 rows' },
|
||||
{ value: 1000, label: '1k rows' },
|
||||
{ value: 5000, label: '5k rows' },
|
||||
{ value: 10000, label: '10k rows' },
|
||||
];
|
||||
|
||||
export const TableControlsWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: ${theme.sizeUnit * 2}px;
|
||||
padding-bottom: ${theme.sizeUnit * 2}px;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -51,6 +61,9 @@ export const TableControls = ({
|
||||
rowcount,
|
||||
isLoading,
|
||||
canDownload,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
onRowLimitChange,
|
||||
}: TableControlsProps) => {
|
||||
const originalTimeColumns = getTimeColumns(datasourceId);
|
||||
const formattedTimeColumns = zip<string, GenericDataType>(
|
||||
@@ -76,9 +89,23 @@ export const TableControls = ({
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`}
|
||||
>
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
{onRowLimitChange && (
|
||||
<Select
|
||||
value={rowLimit}
|
||||
onChange={onRowLimitChange}
|
||||
options={rowLimitOptions}
|
||||
size="small"
|
||||
css={css`
|
||||
min-width: 110px;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
)}
|
||||
{canDownload && (
|
||||
<CopyToClipboardButton data={formattedData} columns={columnNames} />
|
||||
)}
|
||||
|
||||
+73
-45
@@ -20,64 +20,96 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import {
|
||||
TableView,
|
||||
TableSize,
|
||||
EmptyState,
|
||||
Loading,
|
||||
EmptyWrapperType,
|
||||
} from '@superset-ui/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
import { GridSize } from 'src/components/GridTable/constants';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { getDrillPayload } from 'src/components/Chart/DrillDetail/utils';
|
||||
import {
|
||||
useGridColumns,
|
||||
useKeywordFilter,
|
||||
useGridHeight,
|
||||
} from './useGridResultTable';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
const cache = new WeakSet();
|
||||
const GridContainer = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const GridSizer = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
`;
|
||||
|
||||
const cache = new WeakMap();
|
||||
|
||||
const DEFAULT_ROW_LIMIT = 100;
|
||||
|
||||
export const SamplesPane = ({
|
||||
isRequest,
|
||||
datasource,
|
||||
queryFormData,
|
||||
queryForce,
|
||||
setForceQuery,
|
||||
dataSize = 50,
|
||||
isVisible,
|
||||
canDownload,
|
||||
}: SamplesPaneProps) => {
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [rowLimit, setRowLimit] = useState(DEFAULT_ROW_LIMIT);
|
||||
const [data, setData] = useState<Record<string, any>[][]>([]);
|
||||
const [colnames, setColnames] = useState<string[]>([]);
|
||||
const [coltypes, setColtypes] = useState<GenericDataType[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [rowcount, setRowCount] = useState<number>(0);
|
||||
const [responseError, setResponseError] = useState<string>('');
|
||||
const { gridHeight, measuredRef } = useGridHeight();
|
||||
const datasourceId = useMemo(
|
||||
() => `${datasource.id}__${datasource.type}`,
|
||||
[datasource],
|
||||
);
|
||||
|
||||
const handleRowLimitChange = useCallback(
|
||||
(limit: number) => {
|
||||
setRowLimit(limit);
|
||||
cache.delete(queryFormData);
|
||||
},
|
||||
[queryFormData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRequest && queryForce) {
|
||||
cache.delete(datasource);
|
||||
cache.delete(queryFormData);
|
||||
}
|
||||
|
||||
if (isRequest && !cache.has(datasource)) {
|
||||
if (isRequest && !cache.has(queryFormData)) {
|
||||
setIsLoading(true);
|
||||
getDatasourceSamples(datasource.type, datasource.id, queryForce, {})
|
||||
const payload =
|
||||
getDrillPayload(
|
||||
queryFormData as Parameters<typeof getDrillPayload>[0],
|
||||
) ?? {};
|
||||
getDatasourceSamples(
|
||||
datasource.type,
|
||||
datasource.id,
|
||||
queryForce,
|
||||
payload,
|
||||
rowLimit,
|
||||
1,
|
||||
)
|
||||
.then(response => {
|
||||
setData(ensureIsArray(response.data));
|
||||
setColnames(ensureIsArray(response.colnames));
|
||||
setColtypes(ensureIsArray(response.coltypes));
|
||||
setRowCount(response.rowcount);
|
||||
setResponseError('');
|
||||
cache.add(datasource);
|
||||
cache.set(queryFormData, true);
|
||||
if (queryForce) {
|
||||
setForceQuery?.(false);
|
||||
}
|
||||
@@ -92,20 +124,10 @@ export const SamplesPane = ({
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [datasource, isRequest, queryForce]);
|
||||
}, [datasource, queryFormData, isRequest, queryForce, rowLimit]);
|
||||
|
||||
// this is to preserve the order of the columns, even if there are integer values,
|
||||
// while also only grabbing the first column's keys
|
||||
const columns = useTableColumns(
|
||||
colnames,
|
||||
coltypes,
|
||||
data,
|
||||
datasourceId,
|
||||
isVisible,
|
||||
{}, // moreConfig
|
||||
true, // allowHTML
|
||||
);
|
||||
const filteredData = useFilteredTableData(filterText, data);
|
||||
const columns = useGridColumns(colnames, coltypes, data);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -120,7 +142,7 @@ export const SamplesPane = ({
|
||||
return (
|
||||
<>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -128,6 +150,9 @@ export const SamplesPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={isLoading}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<Error>{responseError}</Error>
|
||||
</>
|
||||
@@ -142,7 +167,7 @@ export const SamplesPane = ({
|
||||
return (
|
||||
<>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -150,19 +175,22 @@ export const SamplesPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={isLoading}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<TableView
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
pageSize={dataSize}
|
||||
noDataText={t('No results')}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
className="table-condensed"
|
||||
isPaginationSticky
|
||||
showRowCount={false}
|
||||
size={TableSize.Small}
|
||||
small
|
||||
/>
|
||||
<GridContainer>
|
||||
<GridSizer ref={measuredRef}>
|
||||
<GridTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height={gridHeight}
|
||||
size={GridSize.Small}
|
||||
externalFilter={keywordFilter}
|
||||
showRowNumber
|
||||
/>
|
||||
</GridSizer>
|
||||
</GridContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+49
-40
@@ -17,46 +17,52 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
import { GridSize } from 'src/components/GridTable/constants';
|
||||
import {
|
||||
TableView,
|
||||
TableSize,
|
||||
EmptyWrapperType,
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
useGridColumns,
|
||||
useKeywordFilter,
|
||||
useGridHeight,
|
||||
} from './useGridResultTable';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { SingleQueryResultPaneProp } from '../types';
|
||||
|
||||
const ResultPaneContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const GridContainer = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const GridSizer = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
`;
|
||||
|
||||
export const SingleQueryResultPane = ({
|
||||
data,
|
||||
colnames,
|
||||
coltypes,
|
||||
rowcount,
|
||||
datasourceId,
|
||||
dataSize = 50,
|
||||
isVisible,
|
||||
canDownload,
|
||||
columnDisplayNames,
|
||||
isPaginationSticky = true,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
onRowLimitChange,
|
||||
}: SingleQueryResultPaneProp) => {
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const { gridHeight, measuredRef } = useGridHeight();
|
||||
|
||||
// this is to preserve the order of the columns, even if there are integer values,
|
||||
// while also only grabbing the first column's keys
|
||||
const columns = useTableColumns(
|
||||
colnames,
|
||||
coltypes,
|
||||
data,
|
||||
datasourceId,
|
||||
isVisible,
|
||||
{}, // moreConfig
|
||||
true, // allowHTML
|
||||
columnDisplayNames,
|
||||
);
|
||||
const filteredData = useFilteredTableData(filterText, data);
|
||||
const columns = useGridColumns(colnames, coltypes, data, columnDisplayNames);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -64,9 +70,9 @@ export const SingleQueryResultPane = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResultPaneContainer>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -74,19 +80,22 @@ export const SingleQueryResultPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={false}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={rowLimitOptions}
|
||||
onRowLimitChange={onRowLimitChange}
|
||||
/>
|
||||
<TableView
|
||||
columns={columns}
|
||||
size={TableSize.Small}
|
||||
data={filteredData}
|
||||
pageSize={dataSize}
|
||||
noDataText={t('No results')}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
className="table-condensed"
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={false}
|
||||
small
|
||||
/>
|
||||
</>
|
||||
<GridContainer>
|
||||
<GridSizer ref={measuredRef}>
|
||||
<GridTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height={gridHeight}
|
||||
size={GridSize.Small}
|
||||
externalFilter={keywordFilter}
|
||||
showRowNumber
|
||||
/>
|
||||
</GridSizer>
|
||||
</GridContainer>
|
||||
</ResultPaneContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useCallback, useRef, useState } from 'react';
|
||||
import { getTimeFormatter, safeHtmlSpan, TimeFormats } from '@superset-ui/core';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import type { IRowNode } from 'ag-grid-community';
|
||||
|
||||
const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
||||
|
||||
export function useGridColumns(
|
||||
colnames: string[] | undefined,
|
||||
coltypes: GenericDataType[] | undefined,
|
||||
data: Record<string, any>[] | undefined,
|
||||
columnDisplayNames?: Record<string, string>,
|
||||
) {
|
||||
return useMemo(
|
||||
() =>
|
||||
colnames && data?.length
|
||||
? colnames
|
||||
.filter((column: string) => Object.keys(data[0]).includes(column))
|
||||
.map((key, index) => {
|
||||
const colType = coltypes?.[index];
|
||||
const headerLabel = columnDisplayNames?.[key] ?? key;
|
||||
return {
|
||||
label: key,
|
||||
headerName: headerLabel,
|
||||
render: ({ value }: { value: unknown }) => {
|
||||
if (value === true) {
|
||||
return Constants.BOOL_TRUE_DISPLAY;
|
||||
}
|
||||
if (value === false) {
|
||||
return Constants.BOOL_FALSE_DISPLAY;
|
||||
}
|
||||
if (value === null) {
|
||||
return (
|
||||
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
|
||||
{Constants.NULL_DISPLAY}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (
|
||||
colType === GenericDataType.Temporal &&
|
||||
typeof value === 'number'
|
||||
) {
|
||||
return timeFormatter(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return safeHtmlSpan(value);
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
};
|
||||
})
|
||||
: [],
|
||||
[colnames, data, coltypes, columnDisplayNames],
|
||||
);
|
||||
}
|
||||
|
||||
export function useKeywordFilter(filterText: string) {
|
||||
return useCallback(
|
||||
(node: IRowNode) => {
|
||||
if (filterText && node.data) {
|
||||
const lowerFilter = filterText.toLowerCase();
|
||||
return Object.values(node.data).some(
|
||||
(value: unknown) =>
|
||||
value != null && String(value).toLowerCase().includes(lowerFilter),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[filterText],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the height of an absolutely-positioned inner element that fills
|
||||
* its relative-positioned parent. Uses a callback ref so the ResizeObserver
|
||||
* is created when the element mounts (which may be after initial render if
|
||||
* the component conditionally renders a loading state first).
|
||||
*/
|
||||
export function useGridHeight(fallbackHeight = 400) {
|
||||
const [gridHeight, setGridHeight] = useState(fallbackHeight);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const measuredRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const h = Math.floor(entry.contentRect.height);
|
||||
if (h > 0) {
|
||||
setGridHeight(prev => (prev !== h ? h : prev));
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
observerRef.current = observer;
|
||||
}, []);
|
||||
|
||||
return { gridHeight, measuredRef };
|
||||
}
|
||||
+29
-10
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, ReactElement, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, ReactElement, useCallback } from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneProps, QueryResultInterface } from '../types';
|
||||
import { SingleQueryResultPane } from './SingleQueryResultPane';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
@@ -53,7 +53,6 @@ export const useResultsPane = ({
|
||||
errorMessage,
|
||||
setForceQuery,
|
||||
isVisible,
|
||||
dataSize = 50,
|
||||
canDownload,
|
||||
columnDisplayNames,
|
||||
}: ResultsPaneProps): ReactElement[] => {
|
||||
@@ -61,6 +60,8 @@ export const useResultsPane = ({
|
||||
queryFormData?.viz_type || queryFormData?.vizType,
|
||||
);
|
||||
|
||||
const chartRowLimit = Number(queryFormData?.row_limit) || 10000;
|
||||
const [rowLimit, setRowLimit] = useState(1000);
|
||||
const [resultResp, setResultResp] = useState<QueryResultInterface[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [responseError, setResponseError] = useState<string>('');
|
||||
@@ -69,12 +70,28 @@ export const useResultsPane = ({
|
||||
|
||||
const noOpInputChange = useCallback(() => {}, []);
|
||||
|
||||
// Never exceed the chart's own row_limit
|
||||
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
|
||||
|
||||
const cappedFormData = useMemo(
|
||||
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
|
||||
[queryFormData, effectiveRowLimit],
|
||||
);
|
||||
|
||||
const handleRowLimitChange = useCallback(
|
||||
(limit: number) => {
|
||||
setRowLimit(limit);
|
||||
cache.delete(cappedFormData);
|
||||
},
|
||||
[cappedFormData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// it's an invalid formData when gets a errorMessage
|
||||
if (errorMessage) return;
|
||||
if (isRequest && cache.has(queryFormData)) {
|
||||
if (isRequest && cache.has(cappedFormData)) {
|
||||
setResultResp(
|
||||
ensureIsArray(cache.get(queryFormData)) as QueryResultInterface[],
|
||||
ensureIsArray(cache.get(cappedFormData)) as QueryResultInterface[],
|
||||
);
|
||||
setResponseError('');
|
||||
if (queryForce) {
|
||||
@@ -82,10 +99,10 @@ export const useResultsPane = ({
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (isRequest && !cache.has(queryFormData)) {
|
||||
if (isRequest && !cache.has(cappedFormData)) {
|
||||
setIsLoading(true);
|
||||
getChartDataRequest({
|
||||
formData: queryFormData,
|
||||
formData: cappedFormData,
|
||||
force: queryForce,
|
||||
resultFormat: 'json',
|
||||
resultType: 'results',
|
||||
@@ -94,7 +111,7 @@ export const useResultsPane = ({
|
||||
.then(({ json }) => {
|
||||
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
|
||||
setResponseError('');
|
||||
cache.set(queryFormData, json.result);
|
||||
cache.set(cappedFormData, json.result);
|
||||
if (queryForce) {
|
||||
setForceQuery?.(false);
|
||||
}
|
||||
@@ -108,7 +125,7 @@ export const useResultsPane = ({
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [queryFormData, isRequest]);
|
||||
}, [cappedFormData, isRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage) {
|
||||
@@ -163,11 +180,13 @@ export const useResultsPane = ({
|
||||
colnames={result.colnames}
|
||||
coltypes={result.coltypes}
|
||||
rowcount={result.rowcount}
|
||||
dataSize={dataSize}
|
||||
datasourceId={queryFormData.datasource}
|
||||
isVisible={isVisible}
|
||||
canDownload={canDownload}
|
||||
columnDisplayNames={columnDisplayNames}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
</StyledDiv>
|
||||
));
|
||||
|
||||
+6
-12
@@ -19,16 +19,16 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
import * as copyUtils from 'src/utils/copy';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitForElementToBeRemoved,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
import { DataTablesPane } from '..';
|
||||
import { createDataTablesPaneProps } from './fixture';
|
||||
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('DataTablesPane', () => {
|
||||
// Collapsed/expanded state depends on local storage
|
||||
@@ -175,12 +175,6 @@ describe('DataTablesPane', () => {
|
||||
|
||||
expect(screen.getByText('Action')).toBeVisible();
|
||||
expect(screen.getByText('Horror')).toBeVisible();
|
||||
|
||||
userEvent.type(screen.getByPlaceholderText('Search'), 'hor');
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Action'));
|
||||
expect(screen.getByText('Horror')).toBeVisible();
|
||||
expect(screen.queryByText('Action')).not.toBeInTheDocument();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
|
||||
+7
-8
@@ -20,14 +20,18 @@ import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
screen,
|
||||
render,
|
||||
userEvent,
|
||||
waitForElementToBeRemoved,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ChartMetadata, ChartPlugin, VizType } from '@superset-ui/core';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { ResultsPaneOnDashboard } from '../components';
|
||||
import { createResultsPaneOnDashboardProps } from './fixture';
|
||||
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('ResultsPaneOnDashboard', () => {
|
||||
// render and render errorMessage
|
||||
@@ -126,12 +130,12 @@ describe('ResultsPaneOnDashboard', () => {
|
||||
expect(await findByText('Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
test('force query, render and search', async () => {
|
||||
test('force query, render', async () => {
|
||||
const props = createResultsPaneOnDashboardProps({
|
||||
sliceId: 144,
|
||||
queryForce: true,
|
||||
});
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
const { queryByText } = render(
|
||||
<ResultsPaneOnDashboard {...props} setForceQuery={setForceQuery} />,
|
||||
{
|
||||
useRedux: true,
|
||||
@@ -144,11 +148,6 @@ describe('ResultsPaneOnDashboard', () => {
|
||||
expect(queryByText('2 rows')).toBeVisible();
|
||||
expect(queryByText('Action')).toBeVisible();
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
|
||||
userEvent.type(getByPlaceholderText('Search'), 'hor');
|
||||
await waitForElementToBeRemoved(() => queryByText('Action'));
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
expect(queryByText('Action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('multiple results pane', async () => {
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
render,
|
||||
userEvent,
|
||||
waitForElementToBeRemoved,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { SamplesPane } from '../components';
|
||||
import { createSamplesPaneProps } from './fixture';
|
||||
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SamplesPane', () => {
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=34',
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=34&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [],
|
||||
@@ -40,7 +40,7 @@ describe('SamplesPane', () => {
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35',
|
||||
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [
|
||||
@@ -56,7 +56,7 @@ describe('SamplesPane', () => {
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36',
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36&per_page=100&page=1',
|
||||
400,
|
||||
);
|
||||
|
||||
@@ -91,12 +91,12 @@ describe('SamplesPane', () => {
|
||||
expect(await findByText('Error: Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
test('force query, render and search', async () => {
|
||||
test('force query, render', async () => {
|
||||
const props = createSamplesPaneProps({
|
||||
datasourceId: 35,
|
||||
queryForce: true,
|
||||
});
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
const { queryByText } = render(
|
||||
<SamplesPane {...props} setForceQuery={setForceQuery} />,
|
||||
{
|
||||
useRedux: true,
|
||||
@@ -109,10 +109,5 @@ describe('SamplesPane', () => {
|
||||
expect(queryByText('2 rows')).toBeVisible();
|
||||
expect(queryByText('Action')).toBeVisible();
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
|
||||
userEvent.type(getByPlaceholderText('Search'), 'hor');
|
||||
await waitForElementToBeRemoved(() => queryByText('Action'));
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
expect(queryByText('Action')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,10 @@ export const createSamplesPaneProps = ({
|
||||
({
|
||||
isRequest,
|
||||
datasource: { ...datasource, id: datasourceId },
|
||||
queryFormData: {
|
||||
...queryFormData,
|
||||
datasource: `${datasourceId}__table`,
|
||||
},
|
||||
queryForce,
|
||||
isVisible: true,
|
||||
setForceQuery: jest.fn(),
|
||||
|
||||
@@ -56,10 +56,9 @@ export interface ResultsPaneProps {
|
||||
export interface SamplesPaneProps {
|
||||
isRequest: boolean;
|
||||
datasource: Datasource;
|
||||
queryFormData: LatestQueryFormData;
|
||||
queryForce: boolean;
|
||||
setForceQuery?: SetForceQueryAction;
|
||||
dataSize?: number;
|
||||
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
|
||||
isVisible: boolean;
|
||||
canDownload: boolean;
|
||||
}
|
||||
@@ -74,6 +73,9 @@ export interface TableControlsProps {
|
||||
isLoading: boolean;
|
||||
rowcount: number;
|
||||
canDownload: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
export interface QueryResultInterface {
|
||||
@@ -86,11 +88,11 @@ export interface QueryResultInterface {
|
||||
export interface SingleQueryResultPaneProp extends QueryResultInterface {
|
||||
// {datasource.id}__{datasource.type}, eg: 1__table
|
||||
datasourceId?: string;
|
||||
dataSize?: number;
|
||||
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
|
||||
isVisible: boolean;
|
||||
canDownload: boolean;
|
||||
// Optional map of column/metric name -> verbose label
|
||||
columnDisplayNames?: Record<string, string>;
|
||||
isPaginationSticky?: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,6 @@ const ExploreChartPanel = ({
|
||||
|
||||
const {
|
||||
ref: chartPanelRef,
|
||||
observerRef: resizeObserverRef,
|
||||
width: chartPanelWidth,
|
||||
height: chartPanelHeight,
|
||||
} = useResizeDetectorByObserver();
|
||||
@@ -378,7 +377,6 @@ const ExploreChartPanel = ({
|
||||
flex-direction: column;
|
||||
padding-top: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
ref={resizeObserverRef}
|
||||
>
|
||||
{vizTypeNeedsDataset && (
|
||||
<Alert
|
||||
@@ -481,7 +479,6 @@ const ExploreChartPanel = ({
|
||||
</div>
|
||||
),
|
||||
[
|
||||
resizeObserverRef,
|
||||
showAlertBanner,
|
||||
errorMessage,
|
||||
onQuery,
|
||||
@@ -533,7 +530,7 @@ const ExploreChartPanel = ({
|
||||
document.body.className += ` ${standaloneClass}`;
|
||||
}
|
||||
return (
|
||||
<div id="app" data-test="standalone-app" ref={resizeObserverRef}>
|
||||
<div id="app" data-test="standalone-app">
|
||||
{standaloneChartBody}
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-2
@@ -31,15 +31,16 @@ export default function useResizeDetectorByObserver() {
|
||||
setChartPanelSize({ width, height });
|
||||
}
|
||||
}, []);
|
||||
const { ref: observerRef } = useResizeDetector({
|
||||
// Use targetRef to observe the same element we measure
|
||||
useResizeDetector({
|
||||
refreshMode: 'debounce',
|
||||
refreshRate: 300,
|
||||
onResize,
|
||||
targetRef: ref,
|
||||
});
|
||||
|
||||
return {
|
||||
ref,
|
||||
observerRef,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
Regular → Executable
+9
-10
@@ -93,11 +93,6 @@ export const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
overflow: visible;
|
||||
}
|
||||
i {
|
||||
position: absolute;
|
||||
top: -${({ theme }) => theme.sizeUnit * 5.25}px;
|
||||
left: ${({ theme }) => theme.sizeUnit * 26.75}px;
|
||||
}
|
||||
`;
|
||||
|
||||
class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
@@ -172,17 +167,21 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
this.setState({ newSliceName: event.target.value });
|
||||
}
|
||||
|
||||
onDashboardChange = async (dashboard: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) => {
|
||||
onDashboardChange = async (
|
||||
dashboard:
|
||||
| {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
this.setState({
|
||||
dashboard,
|
||||
tabsData: [],
|
||||
selectedTab: undefined,
|
||||
});
|
||||
|
||||
if (typeof dashboard.value === 'number') {
|
||||
if (dashboard && typeof dashboard.value === 'number') {
|
||||
await this.loadTabs(dashboard.value);
|
||||
}
|
||||
};
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 { render, screen } from 'spec/helpers/testing-library';
|
||||
import { Comparator } from '@superset-ui/chart-controls';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import ConditionalFormattingControl from './ConditionalFormattingControl';
|
||||
import { ConditionalFormattingConfig } from './types';
|
||||
|
||||
const columnOptions = [
|
||||
{ label: 'My Column', value: 'my_col', dataType: GenericDataType.Boolean },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
columnOptions,
|
||||
verboseMap: {} as Record<string, string>,
|
||||
removeIrrelevantConditions: false,
|
||||
label: 'Conditional Formatting',
|
||||
description: 'Test',
|
||||
name: 'conditional_formatting',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders "is false" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsFalse, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is true" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsTrue, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is null" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsNull, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is not null" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsNotNull, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is not null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders verbose column name when available', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsFalse, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(
|
||||
<ConditionalFormattingControl
|
||||
{...defaultProps}
|
||||
verboseMap={{ my_col: 'My Column' }}
|
||||
value={value}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('My Column is false')).toBeInTheDocument();
|
||||
});
|
||||
+5
@@ -136,6 +136,11 @@ const ConditionalFormattingControl = ({
|
||||
return `${targetValueLeft} ${Comparator.LessOrEqual} ${columnName} ${Comparator.LessThan} ${targetValueRight}`;
|
||||
case Comparator.BetweenOrRightEqual:
|
||||
return `${targetValueLeft} ${Comparator.LessThan} ${columnName} ${Comparator.LessOrEqual} ${targetValueRight}`;
|
||||
case Comparator.IsTrue:
|
||||
case Comparator.IsFalse:
|
||||
case Comparator.IsNull:
|
||||
case Comparator.IsNotNull:
|
||||
return `${columnName} ${operator}`;
|
||||
default:
|
||||
return `${columnName} ${operator} ${targetValue}`;
|
||||
}
|
||||
|
||||
+20
@@ -269,6 +269,26 @@ test('will convert from individual comparator to array if the operator changes t
|
||||
).toEqual(Operators.In);
|
||||
});
|
||||
|
||||
test('will preserve boolean false comparator when converting to multi operator', () => {
|
||||
const booleanFalseFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.Equals,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: booleanFalseFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.In);
|
||||
expect(
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0]
|
||||
.comparator,
|
||||
).toEqual([false]);
|
||||
});
|
||||
|
||||
test('will convert from array to individual comparators if the operator changes from multi', () => {
|
||||
const props = setup({
|
||||
adhocFilter: simpleMultiAdhocFilter,
|
||||
|
||||
+3
-2
@@ -199,7 +199,7 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
if (MULTI_OPERATORS.has(operatorId)) {
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator
|
||||
: [currentComparator].filter(element => element);
|
||||
: [currentComparator].filter(element => element != null);
|
||||
} else {
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator[0]
|
||||
@@ -396,7 +396,8 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
};
|
||||
|
||||
const comparatorHasValue =
|
||||
comparator &&
|
||||
comparator != null &&
|
||||
comparator !== '' &&
|
||||
(Array.isArray(comparator)
|
||||
? comparator.length > 0
|
||||
: String(comparator).length > 0);
|
||||
|
||||
@@ -70,3 +70,15 @@ test('Should return correct string when subject and operator are valid values',
|
||||
]),
|
||||
).toBe("subject operator 'comparator', 'comparator-2'");
|
||||
});
|
||||
|
||||
test('Should handle boolean false comparator as a string value', () => {
|
||||
expect(getSimpleSQLExpression(params.subject, params.operator, false)).toBe(
|
||||
"subject operator 'FALSE'",
|
||||
);
|
||||
});
|
||||
|
||||
test('Should handle boolean true comparator as a string value', () => {
|
||||
expect(getSimpleSQLExpression(params.subject, params.operator, true)).toBe(
|
||||
"subject operator 'TRUE'",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -458,7 +458,8 @@ export const getSimpleSQLExpression = (
|
||||
isMulti && Array.isArray(comparator) ? comparator[0] : comparator;
|
||||
const comparatorArray = ensureIsArray(comparator);
|
||||
const isString =
|
||||
firstValue !== undefined && Number.isNaN(Number(firstValue));
|
||||
firstValue !== undefined &&
|
||||
(typeof firstValue === 'boolean' || Number.isNaN(Number(firstValue)));
|
||||
const quote = isString ? "'" : '';
|
||||
const [prefix, suffix] = isMulti ? ['(', ')'] : ['', ''];
|
||||
if (comparatorArray.length > 0 && showComparator) {
|
||||
|
||||
@@ -180,7 +180,7 @@ test('should render schema selector, database selector container, and selects',
|
||||
name: 'Select database or type to search databases',
|
||||
});
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema',
|
||||
});
|
||||
expect(databaseSelect).toBeInTheDocument();
|
||||
expect(schemaSelect).toBeInTheDocument();
|
||||
@@ -211,7 +211,7 @@ test('renders list of options when user clicks on schema', async () => {
|
||||
|
||||
// Schema select will be automatically populated if there is only one schema
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: /select schema or type to search schemas/i,
|
||||
name: /select schema/i,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(schemaSelect).toBeEnabled();
|
||||
@@ -231,7 +231,7 @@ test('searches for a table name', async () => {
|
||||
userEvent.click(await screen.findByText('test-postgres'));
|
||||
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: /select schema or type to search schemas/i,
|
||||
name: /select schema/i,
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: /select table or type to search tables/i,
|
||||
@@ -287,7 +287,7 @@ test('renders a warning icon when a table name has a preexisting dataset', async
|
||||
userEvent.click(await screen.findByText('test-postgres'));
|
||||
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: /select schema or type to search schemas/i,
|
||||
name: /select schema/i,
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: /select table or type to search tables/i,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { useTheme, styled } from '@apache-superset/core/theme';
|
||||
import cx from 'classnames';
|
||||
import { Button, Modal } from '@superset-ui/core/components';
|
||||
import withToasts, {
|
||||
@@ -65,6 +65,7 @@ const TabButton = styled.div`
|
||||
const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
padding: ${({ theme }) => theme.sizeUnit * 6}px;
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -93,6 +94,15 @@ function QueryPreviewModal({
|
||||
currentQueryId: query.id,
|
||||
fetchData,
|
||||
});
|
||||
const theme = useTheme();
|
||||
const codeBlockStyle = {
|
||||
border: 1,
|
||||
borderColor: theme.colorBorder,
|
||||
borderStyle: 'solid',
|
||||
marginTop: theme.sizeUnit * 4,
|
||||
fontSize: theme.fontSize * 0.75,
|
||||
height: theme.sizeUnit * 100,
|
||||
};
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<'user' | 'executed'>('user');
|
||||
|
||||
@@ -157,6 +167,7 @@ function QueryPreviewModal({
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
language="sql"
|
||||
customStyle={codeBlockStyle}
|
||||
>
|
||||
{(currentTab === 'user' ? sql : executed_sql) || ''}
|
||||
</SyntaxHighlighterCopy>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { FunctionComponent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { Button, Modal } from '@superset-ui/core/components';
|
||||
import SyntaxHighlighterCopy from 'src/features/queries/SyntaxHighlighterCopy';
|
||||
import withToasts, {
|
||||
@@ -41,6 +41,7 @@ const QueryLabel = styled.div`
|
||||
const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
padding: 24px;
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -77,6 +78,15 @@ const SavedQueryPreviewModal: FunctionComponent<
|
||||
currentQueryId: savedQuery.id,
|
||||
fetchData,
|
||||
});
|
||||
const theme = useTheme();
|
||||
const codeBlockStyle = {
|
||||
border: 1,
|
||||
borderColor: theme.colorBorder,
|
||||
borderStyle: 'solid',
|
||||
marginTop: theme.sizeUnit * 4,
|
||||
fontSize: theme.fontSize * 0.75,
|
||||
height: theme.sizeUnit * 100,
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="none" onKeyUp={handleKeyPress}>
|
||||
@@ -123,6 +133,7 @@ const SavedQueryPreviewModal: FunctionComponent<
|
||||
language="sql"
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
customStyle={codeBlockStyle}
|
||||
>
|
||||
{savedQuery.sql || ''}
|
||||
</SyntaxHighlighterCopy>
|
||||
|
||||
@@ -189,7 +189,8 @@ test('redirects when no files are provided', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('handles CSV file correctly', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('handles CSV file correctly', async () => {
|
||||
const fileHandle = createMockFileHandle('test.csv');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
|
||||
@@ -219,11 +219,15 @@ if (!isDevMode) {
|
||||
|
||||
// TypeScript type checking and .d.ts generation
|
||||
// SWC handles transpilation; this plugin handles type checking separately.
|
||||
// build: true enables project references so .d.ts files are auto-generated.
|
||||
// build: true enables project references so .d.ts files are auto-generated
|
||||
// across the monorepo when editing plugins/packages.
|
||||
// mode: 'write-references' writes .d.ts output (no manual `npm run plugins:build` needed).
|
||||
// Story files are excluded because they import @storybook-shared which resolves
|
||||
// outside plugin rootDir ("src"), causing errors in --build mode.
|
||||
if (isDevMode) {
|
||||
// Set DISABLE_TS_CHECKER=true to skip this plugin entirely (~2-3 GB savings).
|
||||
// Type errors are still caught by pre-commit and CI.
|
||||
const disableTsChecker = ['true', '1'].includes(
|
||||
(process.env.DISABLE_TS_CHECKER || '').toLowerCase(),
|
||||
);
|
||||
if (isDevMode && !disableTsChecker) {
|
||||
plugins.push(
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
async: true,
|
||||
@@ -535,7 +539,7 @@ const config = {
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
sourceMap: true,
|
||||
sourceMap: !isDevMode,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -619,10 +623,23 @@ const config = {
|
||||
watchOptions: isDevMode
|
||||
? {
|
||||
// Watch all plugin and package source directories
|
||||
ignored: ['**/node_modules', '**/.git', '**/lib', '**/esm', '**/dist'],
|
||||
// Poll less frequently to reduce file handles
|
||||
ignored: [
|
||||
'**/node_modules',
|
||||
'**/.git',
|
||||
'**/lib',
|
||||
'**/esm',
|
||||
'**/dist',
|
||||
'**/.temp_cache',
|
||||
'**/coverage',
|
||||
'**/*.test.*',
|
||||
'**/*.stories.*',
|
||||
'**/cypress-base',
|
||||
'**/*.geojson',
|
||||
],
|
||||
// Poll-based watching is needed in Docker/VM where native fs events
|
||||
// don't propagate from host to container.
|
||||
poll: 2000,
|
||||
// Aggregate changes for 500ms before rebuilding
|
||||
// Aggregate changes before rebuilding
|
||||
aggregateTimeout: 500,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Generated
+65
-65
@@ -10,7 +10,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cookie": "^1.1.1",
|
||||
"hot-shots": "^14.2.0",
|
||||
"hot-shots": "^14.3.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
@@ -23,11 +23,11 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^17.4.0",
|
||||
@@ -748,13 +748,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||
"version": "0.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.4.tgz",
|
||||
"integrity": "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.3",
|
||||
"@eslint/object-schema": "^3.0.4",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.4"
|
||||
},
|
||||
@@ -802,22 +802,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
||||
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.4.tgz",
|
||||
"integrity": "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1"
|
||||
"@eslint/core": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz",
|
||||
"integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -841,9 +841,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.4.tgz",
|
||||
"integrity": "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -851,13 +851,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz",
|
||||
"integrity": "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1",
|
||||
"@eslint/core": "^1.2.0",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1798,9 +1798,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"version": "25.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
|
||||
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2794,18 +2794,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz",
|
||||
"integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz",
|
||||
"integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.3",
|
||||
"@eslint/config-helpers": "^0.5.3",
|
||||
"@eslint/core": "^1.1.1",
|
||||
"@eslint/plugin-kit": "^0.6.1",
|
||||
"@eslint/config-array": "^0.23.4",
|
||||
"@eslint/config-helpers": "^0.5.4",
|
||||
"@eslint/core": "^1.2.0",
|
||||
"@eslint/plugin-kit": "^0.7.0",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
@@ -3464,9 +3464,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hot-shots": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.2.0.tgz",
|
||||
"integrity": "sha512-MiEPF/VsmzY2MnfjxDNTEwrDUa+51WeYugLZkzhEqNsWoY0TgwWH3FIDT7QKzOq6K79A5w3tIBxcdyFWeJ6jbg==",
|
||||
"version": "14.3.1",
|
||||
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.3.1.tgz",
|
||||
"integrity": "sha512-2mKuFf3quca37vsT4u4BW9nUZIaz1uuHSpLG0uFQxdg6QAuNU8QnMU6tpEyX/EJe1oEgOrdmFq+DL0NrBIKhkA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -7044,12 +7044,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"@eslint/config-array": {
|
||||
"version": "0.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||
"version": "0.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.4.tgz",
|
||||
"integrity": "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint/object-schema": "^3.0.3",
|
||||
"@eslint/object-schema": "^3.0.4",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.4"
|
||||
},
|
||||
@@ -7081,18 +7081,18 @@
|
||||
}
|
||||
},
|
||||
"@eslint/config-helpers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
||||
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.4.tgz",
|
||||
"integrity": "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint/core": "^1.1.1"
|
||||
"@eslint/core": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"@eslint/core": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz",
|
||||
"integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
@@ -7105,18 +7105,18 @@
|
||||
"dev": true
|
||||
},
|
||||
"@eslint/object-schema": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.4.tgz",
|
||||
"integrity": "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==",
|
||||
"dev": true
|
||||
},
|
||||
"@eslint/plugin-kit": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz",
|
||||
"integrity": "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint/core": "^1.1.1",
|
||||
"@eslint/core": "^1.2.0",
|
||||
"levn": "^0.4.1"
|
||||
}
|
||||
},
|
||||
@@ -7894,9 +7894,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"version": "25.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
|
||||
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -8578,17 +8578,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"eslint": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz",
|
||||
"integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz",
|
||||
"integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.3",
|
||||
"@eslint/config-helpers": "^0.5.3",
|
||||
"@eslint/core": "^1.1.1",
|
||||
"@eslint/plugin-kit": "^0.6.1",
|
||||
"@eslint/config-array": "^0.23.4",
|
||||
"@eslint/config-helpers": "^0.5.4",
|
||||
"@eslint/core": "^1.2.0",
|
||||
"@eslint/plugin-kit": "^0.7.0",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
@@ -9031,9 +9031,9 @@
|
||||
}
|
||||
},
|
||||
"hot-shots": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.2.0.tgz",
|
||||
"integrity": "sha512-MiEPF/VsmzY2MnfjxDNTEwrDUa+51WeYugLZkzhEqNsWoY0TgwWH3FIDT7QKzOq6K79A5w3tIBxcdyFWeJ6jbg==",
|
||||
"version": "14.3.1",
|
||||
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-14.3.1.tgz",
|
||||
"integrity": "sha512-2mKuFf3quca37vsT4u4BW9nUZIaz1uuHSpLG0uFQxdg6QAuNU8QnMU6tpEyX/EJe1oEgOrdmFq+DL0NrBIKhkA==",
|
||||
"requires": {
|
||||
"unix-dgram": "2.x"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cookie": "^1.1.1",
|
||||
"hot-shots": "^14.2.0",
|
||||
"hot-shots": "^14.3.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
@@ -31,11 +31,11 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^17.4.0",
|
||||
|
||||
@@ -99,6 +99,7 @@ def get_table_metadata(database: Any, table: Table) -> TableMetadataResponse:
|
||||
"columns": payload_columns,
|
||||
"selectStar": database.select_star(
|
||||
table,
|
||||
show_cols=True if columns else False,
|
||||
indent=True,
|
||||
cols=columns,
|
||||
latest_partition=True,
|
||||
|
||||
@@ -54,6 +54,10 @@ Dashboard Management:
|
||||
- generate_dashboard: Create a dashboard from chart IDs
|
||||
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard
|
||||
|
||||
Database Connections:
|
||||
- list_databases: List database connections with advanced filters (1-based pagination)
|
||||
- get_database_info: Get detailed database connection info by ID (backend, capabilities)
|
||||
|
||||
Dataset Management:
|
||||
- list_datasets: List datasets with advanced filters (1-based pagination)
|
||||
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
|
||||
@@ -114,12 +118,14 @@ To create a chart:
|
||||
3. generate_explore_link(dataset_id, config) -> preview interactively
|
||||
4. generate_chart(dataset_id, config, save_chart=True) -> save permanently
|
||||
|
||||
To find your own charts/dashboards:
|
||||
To find your own charts/dashboards/databases:
|
||||
1. get_instance_info -> get current_user.id
|
||||
2. list_charts(filters=[{{"col": "created_by_fk",
|
||||
"opr": "eq", "value": current_user.id}}])
|
||||
3. Or: list_dashboards(filters=[{{"col": "created_by_fk",
|
||||
"opr": "eq", "value": current_user.id}}])
|
||||
4. Or: list_databases(filters=[{{"col": "created_by_fk",
|
||||
"opr": "eq", "value": current_user.id}}])
|
||||
|
||||
To explore data with SQL:
|
||||
1. list_datasets -> find a dataset and note its database_id
|
||||
@@ -168,6 +174,8 @@ Query Examples:
|
||||
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
|
||||
- My dashboards:
|
||||
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
|
||||
- My databases:
|
||||
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
|
||||
|
||||
To modify an existing chart (add filters, change metrics, change dimensions, etc.):
|
||||
1. get_chart_info(chart_id) -> examine current configuration
|
||||
@@ -422,6 +430,7 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
|
||||
get_chart_data,
|
||||
get_chart_info,
|
||||
get_chart_preview,
|
||||
get_chart_type_schema,
|
||||
list_charts,
|
||||
update_chart,
|
||||
update_chart_preview,
|
||||
@@ -432,6 +441,10 @@ from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
||||
get_dashboard_info,
|
||||
list_dashboards,
|
||||
)
|
||||
from superset.mcp_service.database.tool import ( # noqa: F401, E402
|
||||
get_database_info,
|
||||
list_databases,
|
||||
)
|
||||
from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
|
||||
get_dataset_info,
|
||||
list_datasets,
|
||||
|
||||
@@ -434,6 +434,10 @@ def _setup_user_context() -> User | None:
|
||||
"""
|
||||
Set up user context for MCP tool execution.
|
||||
|
||||
Includes retry logic for stale database connections (e.g., SSL dropped
|
||||
by proxy/load balancer after idle periods). On OperationalError, the
|
||||
session is reset and the user lookup is retried once.
|
||||
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
@@ -446,38 +450,55 @@ def _setup_user_context() -> User | None:
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
|
||||
try:
|
||||
user = get_user_from_request()
|
||||
except RuntimeError as e:
|
||||
# No Flask application context (e.g., prompts before middleware runs)
|
||||
# This is expected for some FastMCP operations - return None gracefully
|
||||
if "application context" in str(e):
|
||||
logger.debug("No Flask app context available for user setup")
|
||||
return None
|
||||
raise
|
||||
except ValueError as e:
|
||||
# JWT user resolution failed (e.g. SAML subject not in DB).
|
||||
# If middleware already set g.user (request context exists),
|
||||
# use that instead of failing closed.
|
||||
from flask import has_request_context
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
if has_request_context() and hasattr(g, "user") and g.user:
|
||||
logger.warning(
|
||||
"JWT user resolution failed (%s), using middleware-provided g.user=%s",
|
||||
e,
|
||||
g.user.username,
|
||||
)
|
||||
# Assign to local so relationship validation below runs
|
||||
# (same as the normal path) to prevent detached instance errors.
|
||||
user = g.user
|
||||
else:
|
||||
user = None # Ensure defined before loop in case of unexpected exit
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
user = get_user_from_request()
|
||||
|
||||
# Validate user has necessary relationships loaded.
|
||||
# Force access to ensure they're loaded if lazy.
|
||||
# This is inside the retry loop because relationship loading
|
||||
# also hits the DB and can fail on stale SSL connections.
|
||||
user_roles = user.roles # noqa: F841
|
||||
if hasattr(user, "groups"):
|
||||
user_groups = user.groups # noqa: F841
|
||||
|
||||
break
|
||||
except RuntimeError as e:
|
||||
# No Flask application context (e.g., prompts before middleware runs)
|
||||
if "application context" in str(e):
|
||||
logger.debug("No Flask app context available for user setup")
|
||||
return None
|
||||
raise
|
||||
except OperationalError as e:
|
||||
if attempt == 0:
|
||||
# Only retry on connection-level errors (SSL drops, server
|
||||
# closed connection). Other OperationalErrors (e.g., lock
|
||||
# timeouts) are unlikely to succeed on immediate retry but
|
||||
# are bounded to one attempt so the cost is acceptable.
|
||||
logger.warning(
|
||||
"Stale DB connection during user setup (attempt 1), "
|
||||
"resetting session and retrying: %s",
|
||||
e,
|
||||
)
|
||||
_cleanup_session_on_error()
|
||||
continue
|
||||
logger.error("DB connection failed on retry during user setup: %s", e)
|
||||
_cleanup_session_on_error()
|
||||
raise
|
||||
except ValueError as e:
|
||||
# User resolution failed — fail closed. Do not fall back to
|
||||
# g.user from middleware, as that could allow a request to
|
||||
# proceed as a different user in multi-tenant deployments.
|
||||
# Clear g.user so error/audit logging doesn't attribute
|
||||
# the denied request to the middleware-provided identity.
|
||||
logger.error("MCP user resolution failed, denying request: %s", e)
|
||||
if has_request_context():
|
||||
g.pop("user", None)
|
||||
raise
|
||||
|
||||
# Validate user has necessary relationships loaded
|
||||
# (Force access to ensure they're loaded if lazy)
|
||||
user_roles = user.roles # noqa: F841
|
||||
if hasattr(user, "groups"):
|
||||
user_groups = user.groups # noqa: F841
|
||||
|
||||
g.user = user
|
||||
return user
|
||||
|
||||
@@ -36,6 +36,7 @@ from pydantic import (
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
TypeAdapter,
|
||||
)
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -74,6 +75,8 @@ class ChartLike(Protocol):
|
||||
cache_timeout: int | None
|
||||
form_data: Dict[str, Any] | None
|
||||
query_context: Any | None
|
||||
certified_by: str | None
|
||||
certification_details: str | None
|
||||
changed_by: Any | None # User object
|
||||
changed_by_name: str | None
|
||||
changed_on: str | datetime | None
|
||||
@@ -113,6 +116,12 @@ class ChartInfo(BaseModel):
|
||||
created_on_humanized: str | None = Field(
|
||||
None, description="Humanized creation time"
|
||||
)
|
||||
certified_by: str | None = Field(
|
||||
None, description="Name of the person or team who certified this chart"
|
||||
)
|
||||
certification_details: str | None = Field(
|
||||
None, description="Certification details or reason"
|
||||
)
|
||||
uuid: str | None = Field(None, description="Chart UUID")
|
||||
tags: List[TagInfo] = Field(default_factory=list, description="Chart tags")
|
||||
owners: List[UserInfo] = Field(default_factory=list, description="Chart owners")
|
||||
@@ -284,14 +293,25 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
|
||||
if not chart:
|
||||
return None
|
||||
|
||||
# Use the chart's native URL (explore URL) instead of screenshot URL
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
from superset.utils import json as utils_json
|
||||
|
||||
chart_id = getattr(chart, "id", None)
|
||||
chart_url = None
|
||||
if chart_id:
|
||||
chart_url = f"{get_superset_base_url()}/explore/?slice_id={chart_id}"
|
||||
|
||||
# Parse form_data from the chart's params JSON string
|
||||
chart_params = getattr(chart, "params", None)
|
||||
chart_form_data = None
|
||||
if chart_params and isinstance(chart_params, str):
|
||||
try:
|
||||
chart_form_data = utils_json.loads(chart_params)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif isinstance(chart_params, dict):
|
||||
chart_form_data = chart_params
|
||||
|
||||
return ChartInfo(
|
||||
id=chart_id,
|
||||
slice_name=getattr(chart, "slice_name", None),
|
||||
@@ -300,7 +320,10 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
|
||||
datasource_type=getattr(chart, "datasource_type", None),
|
||||
url=chart_url,
|
||||
description=getattr(chart, "description", None),
|
||||
certified_by=getattr(chart, "certified_by", None),
|
||||
certification_details=getattr(chart, "certification_details", None),
|
||||
cache_timeout=getattr(chart, "cache_timeout", None),
|
||||
form_data=chart_form_data,
|
||||
changed_by=getattr(chart, "changed_by_name", None)
|
||||
or (str(chart.changed_by) if getattr(chart, "changed_by", None) else None),
|
||||
changed_by_name=getattr(chart, "changed_by_name", None),
|
||||
@@ -1123,7 +1146,7 @@ class XYChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
# Discriminated union entry point with custom error handling
|
||||
# Discriminated union for runtime validation (not exposed in JSON Schema)
|
||||
ChartConfig = Annotated[
|
||||
XYChartConfig
|
||||
| TableChartConfig
|
||||
@@ -1142,6 +1165,66 @@ ChartConfig = Annotated[
|
||||
),
|
||||
]
|
||||
|
||||
# Module-level TypeAdapter avoids repeated schema compilation in
|
||||
# parse_chart_config() — safe because ChartConfig is fully defined above.
|
||||
_CHART_CONFIG_ADAPTER: TypeAdapter[ChartConfig] = TypeAdapter(ChartConfig)
|
||||
|
||||
# Compact description for JSON Schema — keeps tool inputSchema small while
|
||||
# giving LLMs enough context to construct valid configs.
|
||||
_CHART_CONFIG_DESCRIPTION = (
|
||||
"Chart configuration object. MUST include 'chart_type' to select the "
|
||||
"schema. Types: 'xy' (x, y, kind: line/bar/area/scatter), "
|
||||
"'table' (columns), 'pie' (dimension, metric), "
|
||||
"'pivot_table' (rows, metrics), 'mixed_timeseries' (x, y, y_secondary), "
|
||||
"'handlebars' (columns, handlebars_template), "
|
||||
"'big_number' (metric). "
|
||||
"See chart://configs resource for full field reference and examples."
|
||||
)
|
||||
|
||||
|
||||
def parse_chart_config(
|
||||
config: Dict[str, Any],
|
||||
) -> (
|
||||
XYChartConfig
|
||||
| TableChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig
|
||||
| BigNumberChartConfig
|
||||
):
|
||||
"""Parse a raw dict into the appropriate typed ChartConfig subclass.
|
||||
|
||||
Validates the dict against the discriminated union using chart_type.
|
||||
Call this in tool function bodies to get a typed config object.
|
||||
"""
|
||||
try:
|
||||
return _CHART_CONFIG_ADAPTER.validate_python(config)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"{e}\n\n"
|
||||
f"Hint: read the chart://configs resource for valid configuration "
|
||||
f"examples and field reference."
|
||||
) from e
|
||||
|
||||
|
||||
def _coerce_config_to_dict(v: Any) -> Dict[str, Any]:
|
||||
"""Accept ChartConfig objects, dicts, or JSON strings for the config field."""
|
||||
if isinstance(v, str):
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
try:
|
||||
v = json_utils.loads(v)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f"config must be a JSON object string, got: {v!r}"
|
||||
) from exc
|
||||
if hasattr(v, "model_dump"):
|
||||
return v.model_dump()
|
||||
if isinstance(v, dict):
|
||||
return v
|
||||
raise TypeError(f"config must be a dict or JSON string, got {type(v).__name__}")
|
||||
|
||||
|
||||
class ListChartsRequest(MetadataCacheControl):
|
||||
"""Request schema for list_charts with clear, unambiguous types."""
|
||||
@@ -1237,7 +1320,7 @@ class ListChartsRequest(MetadataCacheControl):
|
||||
# The tool input models
|
||||
class GenerateChartRequest(QueryCacheControl):
|
||||
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
|
||||
config: ChartConfig = Field(..., description="Chart configuration")
|
||||
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
|
||||
chart_name: str | None = Field(
|
||||
None, description="Auto-generates if omitted", max_length=255
|
||||
)
|
||||
@@ -1247,6 +1330,11 @@ class GenerateChartRequest(QueryCacheControl):
|
||||
default_factory=lambda: ["url"],
|
||||
)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any]:
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
@field_validator("chart_name")
|
||||
@classmethod
|
||||
def sanitize_chart_name(cls, v: str | None) -> str | None:
|
||||
@@ -1279,12 +1367,22 @@ class GenerateChartRequest(QueryCacheControl):
|
||||
|
||||
class GenerateExploreLinkRequest(FormDataCacheControl):
|
||||
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
|
||||
config: ChartConfig = Field(..., description="Chart configuration")
|
||||
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any]:
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
|
||||
class UpdateChartRequest(QueryCacheControl):
|
||||
identifier: int | str = Field(..., description="Chart ID or UUID")
|
||||
config: ChartConfig
|
||||
config: Dict[str, Any] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
f"{_CHART_CONFIG_DESCRIPTION} Optional; omit to only update chart_name."
|
||||
),
|
||||
)
|
||||
chart_name: str | None = Field(
|
||||
None, description="Auto-generates if omitted", max_length=255
|
||||
)
|
||||
@@ -1293,6 +1391,13 @@ class UpdateChartRequest(QueryCacheControl):
|
||||
default_factory=lambda: ["url"],
|
||||
)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any] | None:
|
||||
if v is None:
|
||||
return None
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
@field_validator("chart_name")
|
||||
@classmethod
|
||||
def sanitize_chart_name(cls, v: str | None) -> str | None:
|
||||
@@ -1303,12 +1408,17 @@ class UpdateChartRequest(QueryCacheControl):
|
||||
class UpdateChartPreviewRequest(FormDataCacheControl):
|
||||
form_data_key: str = Field(..., description="Existing form_data_key to update")
|
||||
dataset_id: int | str = Field(..., description="Dataset ID or UUID")
|
||||
config: ChartConfig
|
||||
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
|
||||
generate_preview: bool = True
|
||||
preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field(
|
||||
default_factory=lambda: ["url"],
|
||||
)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any]:
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
|
||||
class GetChartDataRequest(QueryCacheControl):
|
||||
"""Request for chart data with cache control.
|
||||
|
||||
@@ -19,6 +19,7 @@ from .generate_chart import generate_chart
|
||||
from .get_chart_data import get_chart_data
|
||||
from .get_chart_info import get_chart_info
|
||||
from .get_chart_preview import get_chart_preview
|
||||
from .get_chart_type_schema import get_chart_type_schema
|
||||
from .list_charts import list_charts
|
||||
from .update_chart import update_chart
|
||||
from .update_chart_preview import update_chart_preview
|
||||
@@ -31,4 +32,5 @@ __all__ = [
|
||||
"update_chart_preview",
|
||||
"get_chart_preview",
|
||||
"get_chart_data",
|
||||
"get_chart_type_schema",
|
||||
]
|
||||
|
||||
@@ -43,6 +43,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
ChartError,
|
||||
GenerateChartRequest,
|
||||
GenerateChartResponse,
|
||||
parse_chart_config,
|
||||
PerformanceMetadata,
|
||||
)
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
@@ -209,13 +210,17 @@ async def generate_chart( # noqa: C901
|
||||
"save_chart=%s, preview_formats=%s"
|
||||
% (
|
||||
request.dataset_id,
|
||||
request.config.chart_type,
|
||||
request.config.get("chart_type", "unknown"),
|
||||
request.save_chart,
|
||||
request.preview_formats,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Chart configuration details: config=%s" % (request.config.model_dump(),)
|
||||
"Chart configuration details: chart_type=%s, keys=%s"
|
||||
% (
|
||||
request.config.get("chart_type", "unknown"),
|
||||
sorted(request.config.keys()),
|
||||
)
|
||||
)
|
||||
|
||||
# Track runtime warnings to include in response
|
||||
@@ -269,11 +274,12 @@ async def generate_chart( # noqa: C901
|
||||
}
|
||||
)
|
||||
|
||||
# Parse the raw config dict into a typed ChartConfig for downstream use
|
||||
config = parse_chart_config(request.config)
|
||||
|
||||
# Map the simplified config to Superset's form_data format
|
||||
# Pass dataset_id to enable column type checking for proper viz_type selection
|
||||
form_data = map_config_to_form_data(
|
||||
request.config, dataset_id=request.dataset_id
|
||||
)
|
||||
form_data = map_config_to_form_data(config, dataset_id=request.dataset_id)
|
||||
|
||||
chart = None
|
||||
chart_id = None
|
||||
@@ -367,7 +373,7 @@ async def generate_chart( # noqa: C901
|
||||
dataset, "table_name", None
|
||||
)
|
||||
chart_name = request.chart_name or generate_chart_name(
|
||||
request.config, dataset_name=dataset_name
|
||||
config, dataset_name=dataset_name
|
||||
)
|
||||
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
|
||||
|
||||
@@ -607,8 +613,8 @@ async def generate_chart( # noqa: C901
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
# Generate semantic analysis
|
||||
capabilities = analyze_chart_capabilities(chart, request.config)
|
||||
semantics = analyze_chart_semantics(chart, request.config)
|
||||
capabilities = analyze_chart_capabilities(chart, config)
|
||||
semantics = analyze_chart_semantics(chart, config)
|
||||
|
||||
# Create performance metadata
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
@@ -622,7 +628,7 @@ async def generate_chart( # noqa: C901
|
||||
chart_name = (
|
||||
chart.slice_name
|
||||
if chart and hasattr(chart, "slice_name")
|
||||
else generate_chart_name(request.config)
|
||||
else generate_chart_name(config)
|
||||
)
|
||||
accessibility = AccessibilityMetadata(
|
||||
color_blind_safe=True, # Would need actual analysis
|
||||
@@ -843,9 +849,9 @@ async def generate_chart( # noqa: C901
|
||||
# Extract chart_type from different sources for better error context
|
||||
chart_type = "unknown"
|
||||
try:
|
||||
if hasattr(request, "config") and hasattr(request.config, "chart_type"):
|
||||
chart_type = request.config.chart_type
|
||||
except AttributeError as extract_error:
|
||||
if hasattr(request, "config") and isinstance(request.config, dict):
|
||||
chart_type = request.config.get("chart_type", "unknown")
|
||||
except (AttributeError, TypeError) as extract_error:
|
||||
# Ignore errors when extracting chart type for error context
|
||||
logger.debug("Could not extract chart type: %s", extract_error)
|
||||
|
||||
|
||||
@@ -301,7 +301,13 @@ async def get_chart_data( # noqa: C901
|
||||
cached_groupby: list[str] = []
|
||||
else:
|
||||
cached_metrics = cached_form_data_dict.get("metrics", [])
|
||||
cached_groupby = cached_form_data_dict.get("groupby", [])
|
||||
raw_groupby = cached_form_data_dict.get("groupby", [])
|
||||
# Guard against string groupby (e.g. heatmap_v2 migrated
|
||||
# from legacy heatmap where all_columns_y was a string)
|
||||
if isinstance(raw_groupby, str):
|
||||
cached_groupby = [raw_groupby]
|
||||
else:
|
||||
cached_groupby = list(raw_groupby)
|
||||
|
||||
_apply_extra_form_data(cached_form_data_dict, request.extra_form_data)
|
||||
|
||||
@@ -443,7 +449,13 @@ async def get_chart_data( # noqa: C901
|
||||
else:
|
||||
# Standard charts use "metrics" (plural) and "groupby"
|
||||
metrics = form_data.get("metrics", [])
|
||||
groupby_columns = list(form_data.get("groupby") or [])
|
||||
raw_groupby = form_data.get("groupby") or []
|
||||
# Guard against string groupby (e.g. heatmap_v2 migrated
|
||||
# from legacy heatmap where all_columns_y was a string)
|
||||
if isinstance(raw_groupby, str):
|
||||
groupby_columns = [raw_groupby]
|
||||
else:
|
||||
groupby_columns = list(raw_groupby)
|
||||
# Some chart types use "columns" instead of "groupby"
|
||||
if not groupby_columns:
|
||||
form_columns = form_data.get("columns")
|
||||
|
||||
@@ -146,6 +146,16 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
|
||||
if "column_name" in x_axis_config:
|
||||
columns.append(x_axis_config["column_name"])
|
||||
|
||||
if not columns and not metrics:
|
||||
return ChartError(
|
||||
error=(
|
||||
"Cannot generate ASCII preview: chart has no columns or "
|
||||
"metrics in its configuration. This chart type may not "
|
||||
"support ASCII preview."
|
||||
),
|
||||
error_type="UnsupportedChart",
|
||||
)
|
||||
|
||||
factory = QueryContextFactory()
|
||||
query_context = factory.create(
|
||||
datasource={
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
MCP tool: get_chart_type_schema
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
BigNumberChartConfig,
|
||||
HandlebarsChartConfig,
|
||||
MixedTimeseriesChartConfig,
|
||||
PieChartConfig,
|
||||
PivotTableChartConfig,
|
||||
TableChartConfig,
|
||||
XYChartConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level TypeAdapters — one per chart type, compiled once.
|
||||
_CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
|
||||
"xy": TypeAdapter(XYChartConfig),
|
||||
"table": TypeAdapter(TableChartConfig),
|
||||
"pie": TypeAdapter(PieChartConfig),
|
||||
"pivot_table": TypeAdapter(PivotTableChartConfig),
|
||||
"mixed_timeseries": TypeAdapter(MixedTimeseriesChartConfig),
|
||||
"handlebars": TypeAdapter(HandlebarsChartConfig),
|
||||
"big_number": TypeAdapter(BigNumberChartConfig),
|
||||
}
|
||||
|
||||
VALID_CHART_TYPES = sorted(_CHART_TYPE_ADAPTERS.keys())
|
||||
|
||||
# Per-type examples — lightweight inline examples for each chart type.
|
||||
_CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
|
||||
"xy": [
|
||||
{
|
||||
"chart_type": "xy",
|
||||
"kind": "line",
|
||||
"x": {"name": "order_date"},
|
||||
"y": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"time_grain": "P1D",
|
||||
},
|
||||
{
|
||||
"chart_type": "xy",
|
||||
"kind": "bar",
|
||||
"x": {"name": "category"},
|
||||
"y": [{"name": "sales", "aggregate": "SUM"}],
|
||||
},
|
||||
],
|
||||
"table": [
|
||||
{
|
||||
"chart_type": "table",
|
||||
"columns": [
|
||||
{"name": "customer_name"},
|
||||
{"name": "revenue", "aggregate": "SUM"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"pie": [
|
||||
{
|
||||
"chart_type": "pie",
|
||||
"dimension": {"name": "region"},
|
||||
"metric": {"name": "revenue", "aggregate": "SUM"},
|
||||
},
|
||||
],
|
||||
"pivot_table": [
|
||||
{
|
||||
"chart_type": "pivot_table",
|
||||
"rows": [{"name": "region"}],
|
||||
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"columns": [{"name": "quarter"}],
|
||||
},
|
||||
],
|
||||
"mixed_timeseries": [
|
||||
{
|
||||
"chart_type": "mixed_timeseries",
|
||||
"x": {"name": "order_date"},
|
||||
"y": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"y_secondary": [{"name": "orders", "aggregate": "COUNT"}],
|
||||
"time_grain": "P1M",
|
||||
},
|
||||
],
|
||||
"handlebars": [
|
||||
{
|
||||
"chart_type": "handlebars",
|
||||
"query_mode": "raw",
|
||||
"columns": [{"name": "customer_name"}, {"name": "email"}],
|
||||
"handlebars_template": "{{#each data}}<p>{{customer_name}}</p>{{/each}}",
|
||||
},
|
||||
],
|
||||
"big_number": [
|
||||
{
|
||||
"chart_type": "big_number",
|
||||
"metric": {"name": "revenue", "aggregate": "SUM"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _get_chart_type_schema_impl(
|
||||
chart_type: str,
|
||||
include_examples: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pure logic for chart type schema lookup — no auth, no decorators."""
|
||||
adapter = _CHART_TYPE_ADAPTERS.get(chart_type)
|
||||
if adapter is None:
|
||||
return {
|
||||
"error": f"Unknown chart_type: {chart_type!r}",
|
||||
"valid_chart_types": VALID_CHART_TYPES,
|
||||
"hint": (
|
||||
"Use one of the valid chart_type values listed above. "
|
||||
"Call this tool again with a valid chart_type to see "
|
||||
"its schema and examples."
|
||||
),
|
||||
}
|
||||
|
||||
schema = adapter.json_schema()
|
||||
result: Dict[str, Any] = {
|
||||
"chart_type": chart_type,
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
if include_examples:
|
||||
result["examples"] = _CHART_EXAMPLES.get(chart_type, [])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
annotations=ToolAnnotations(
|
||||
title="Get chart type schema",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
def get_chart_type_schema(
|
||||
chart_type: str,
|
||||
include_examples: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the full JSON Schema and examples for a specific chart type.
|
||||
|
||||
Use this tool to discover the exact fields, types, and constraints
|
||||
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.
|
||||
|
||||
Returns the JSON Schema for the requested chart type, optionally
|
||||
with working examples.
|
||||
"""
|
||||
return _get_chart_type_schema_impl(chart_type, include_examples)
|
||||
@@ -46,6 +46,9 @@ DEFAULT_CHART_COLUMNS = [
|
||||
"id",
|
||||
"slice_name",
|
||||
"viz_type",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
|
||||
@@ -21,6 +21,7 @@ MCP tool: update_chart
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -37,6 +38,7 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
AccessibilityMetadata,
|
||||
GenerateChartResponse,
|
||||
parse_chart_config,
|
||||
PerformanceMetadata,
|
||||
UpdateChartRequest,
|
||||
)
|
||||
@@ -46,6 +48,67 @@ from superset.utils import json
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_chart(identifier: int | str) -> Any | None:
|
||||
"""Find a chart by numeric ID or UUID string."""
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
if isinstance(identifier, int) or (
|
||||
isinstance(identifier, str) and identifier.isdigit()
|
||||
):
|
||||
chart_id = int(identifier) if isinstance(identifier, str) else identifier
|
||||
return ChartDAO.find_by_id(chart_id)
|
||||
return ChartDAO.find_by_id(identifier, id_column="uuid")
|
||||
|
||||
|
||||
def _build_update_payload(
|
||||
request: UpdateChartRequest,
|
||||
chart: Any,
|
||||
) -> dict[str, Any] | GenerateChartResponse:
|
||||
"""Build the update payload for a chart update.
|
||||
|
||||
Returns a dict payload on success, or a GenerateChartResponse error
|
||||
when neither config nor chart_name is provided.
|
||||
"""
|
||||
if request.config is not None:
|
||||
config = parse_chart_config(request.config)
|
||||
dataset_id = chart.datasource_id if chart.datasource_id else None
|
||||
new_form_data = map_config_to_form_data(config, dataset_id=dataset_id)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
|
||||
chart_name = (
|
||||
request.chart_name
|
||||
if request.chart_name
|
||||
else chart.slice_name or generate_chart_name(config)
|
||||
)
|
||||
|
||||
return {
|
||||
"slice_name": chart_name,
|
||||
"viz_type": new_form_data["viz_type"],
|
||||
"params": json.dumps(new_form_data),
|
||||
}
|
||||
|
||||
# Name-only update: keep existing visualization, just rename
|
||||
if not request.chart_name:
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": {
|
||||
"error_type": "ValidationError",
|
||||
"message": ("Either 'config' or 'chart_name' must be provided."),
|
||||
"details": (
|
||||
"Either 'config' or 'chart_name' must be provided. "
|
||||
"Use config for visualization changes, chart_name "
|
||||
"for renaming."
|
||||
),
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
return {"slice_name": request.chart_name}
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Chart",
|
||||
@@ -105,29 +168,22 @@ async def update_chart(
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Find the existing chart
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart.chart_lookup"):
|
||||
chart = None
|
||||
if isinstance(request.identifier, int) or (
|
||||
isinstance(request.identifier, str) and request.identifier.isdigit()
|
||||
):
|
||||
chart_id = (
|
||||
int(request.identifier)
|
||||
if isinstance(request.identifier, str)
|
||||
else request.identifier
|
||||
)
|
||||
chart = ChartDAO.find_by_id(chart_id)
|
||||
else:
|
||||
# Try UUID lookup using DAO flexible method
|
||||
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
|
||||
chart = _find_chart(request.identifier)
|
||||
|
||||
if not chart:
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": f"No chart found with identifier: {request.identifier}",
|
||||
"error": {
|
||||
"error_type": "NotFound",
|
||||
"message": (
|
||||
f"No chart found with identifier: {request.identifier}"
|
||||
),
|
||||
"details": (
|
||||
f"No chart found with identifier: {request.identifier}"
|
||||
),
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
@@ -157,35 +213,23 @@ async def update_chart(
|
||||
}
|
||||
)
|
||||
|
||||
# Map the new config to form_data format
|
||||
# Get dataset_id from existing chart for column type checking
|
||||
dataset_id = chart.datasource_id if chart.datasource_id else None
|
||||
new_form_data = map_config_to_form_data(request.config, dataset_id=dataset_id)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
|
||||
# Update chart using Superset's command
|
||||
# Build update payload (config update or name-only rename)
|
||||
from superset.commands.chart.update import UpdateChartCommand
|
||||
|
||||
payload_or_error = _build_update_payload(request, chart)
|
||||
if isinstance(payload_or_error, GenerateChartResponse):
|
||||
return payload_or_error
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart.db_write"):
|
||||
# Generate new chart name if provided, otherwise keep existing
|
||||
chart_name = (
|
||||
request.chart_name
|
||||
if request.chart_name
|
||||
else chart.slice_name or generate_chart_name(request.config)
|
||||
)
|
||||
|
||||
update_payload = {
|
||||
"slice_name": chart_name,
|
||||
"viz_type": new_form_data["viz_type"],
|
||||
"params": json.dumps(new_form_data),
|
||||
}
|
||||
|
||||
command = UpdateChartCommand(chart.id, update_payload)
|
||||
command = UpdateChartCommand(chart.id, payload_or_error)
|
||||
updated_chart = command.run()
|
||||
|
||||
# Parse config for analysis (may be None for name-only updates)
|
||||
config = parse_chart_config(request.config) if request.config else None
|
||||
|
||||
# Generate semantic analysis
|
||||
capabilities = analyze_chart_capabilities(updated_chart, request.config)
|
||||
semantics = analyze_chart_semantics(updated_chart, request.config)
|
||||
capabilities = analyze_chart_capabilities(updated_chart, config)
|
||||
semantics = analyze_chart_semantics(updated_chart, config)
|
||||
|
||||
# Create performance metadata
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
@@ -199,7 +243,7 @@ async def update_chart(
|
||||
chart_name = (
|
||||
updated_chart.slice_name
|
||||
if updated_chart and hasattr(updated_chart, "slice_name")
|
||||
else generate_chart_name(request.config)
|
||||
else (generate_chart_name(config) if config else "Updated chart")
|
||||
)
|
||||
accessibility = AccessibilityMetadata(
|
||||
color_blind_safe=True, # Would need actual analysis
|
||||
@@ -288,7 +332,11 @@ async def update_chart(
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": f"Chart update failed: {str(e)}",
|
||||
"error": {
|
||||
"error_type": type(e).__name__,
|
||||
"message": f"Chart update failed: {e}",
|
||||
"details": str(e),
|
||||
},
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
|
||||
@@ -36,6 +36,7 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
)
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
AccessibilityMetadata,
|
||||
parse_chart_config,
|
||||
PerformanceMetadata,
|
||||
UpdateChartPreviewRequest,
|
||||
)
|
||||
@@ -95,20 +96,20 @@ def update_chart_preview(
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Parse the raw config dict into a typed ChartConfig
|
||||
config = parse_chart_config(request.config)
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart_preview.form_data"):
|
||||
# Map the new config to form_data format
|
||||
# Pass dataset_id to enable column type checking
|
||||
new_form_data = map_config_to_form_data(
|
||||
request.config, dataset_id=request.dataset_id
|
||||
config, dataset_id=request.dataset_id
|
||||
)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
|
||||
# Preserve adhoc filters from the previous cached form_data
|
||||
# when the new config doesn't explicitly specify filters
|
||||
if (
|
||||
getattr(request.config, "filters", None) is None
|
||||
and request.form_data_key
|
||||
):
|
||||
if getattr(config, "filters", None) is None and request.form_data_key:
|
||||
old_adhoc_filters = _get_old_adhoc_filters(request.form_data_key)
|
||||
if old_adhoc_filters:
|
||||
new_form_data["adhoc_filters"] = old_adhoc_filters
|
||||
@@ -123,8 +124,8 @@ def update_chart_preview(
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart_preview.metadata"):
|
||||
# Generate semantic analysis
|
||||
capabilities = analyze_chart_capabilities(None, request.config)
|
||||
semantics = analyze_chart_semantics(None, request.config)
|
||||
capabilities = analyze_chart_capabilities(None, config)
|
||||
semantics = analyze_chart_semantics(None, config)
|
||||
|
||||
# Create performance metadata
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
@@ -135,7 +136,7 @@ def update_chart_preview(
|
||||
)
|
||||
|
||||
# Create accessibility metadata
|
||||
chart_name = generate_chart_name(request.config)
|
||||
chart_name = generate_chart_name(config)
|
||||
accessibility = AccessibilityMetadata(
|
||||
color_blind_safe=True, # Would need actual analysis
|
||||
alt_text=f"Updated chart preview showing {chart_name}",
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Any, Dict, List, Tuple
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ChartConfig,
|
||||
GenerateChartRequest,
|
||||
parse_chart_config,
|
||||
)
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
@@ -171,6 +172,10 @@ class ValidationPipeline:
|
||||
if request is None:
|
||||
return ValidationResult(is_valid=False, error=error)
|
||||
|
||||
# Parse the raw config dict into a typed ChartConfig for
|
||||
# downstream validators that need typed access.
|
||||
typed_config = parse_chart_config(request.config)
|
||||
|
||||
# Fetch dataset context once and reuse across validation layers
|
||||
dataset_context = ValidationPipeline._get_dataset_context(
|
||||
request.dataset_id
|
||||
@@ -178,20 +183,20 @@ class ValidationPipeline:
|
||||
|
||||
# Layer 2: Dataset validation (reuses context)
|
||||
is_valid, error = ValidationPipeline._validate_dataset(
|
||||
request.config, request.dataset_id, dataset_context
|
||||
typed_config, request.dataset_id, dataset_context
|
||||
)
|
||||
if not is_valid:
|
||||
return ValidationResult(is_valid=False, request=request, error=error)
|
||||
|
||||
# Layer 3: Runtime validation - returns warnings as metadata, not errors
|
||||
_is_valid, warnings_metadata = ValidationPipeline._validate_runtime(
|
||||
request.config, request.dataset_id
|
||||
typed_config, request.dataset_id
|
||||
)
|
||||
# Runtime validation always returns True now, warnings are informational
|
||||
|
||||
# Layer 4: Column name normalization (reuses context)
|
||||
normalized_request = ValidationPipeline._normalize_column_names(
|
||||
request, dataset_context
|
||||
request, dataset_context, typed_config=typed_config
|
||||
)
|
||||
|
||||
return ValidationResult(
|
||||
@@ -284,6 +289,7 @@ class ValidationPipeline:
|
||||
def _normalize_column_names(
|
||||
request: GenerateChartRequest,
|
||||
dataset_context: DatasetContext | None = None,
|
||||
typed_config: ChartConfig | None = None,
|
||||
) -> GenerateChartRequest:
|
||||
"""
|
||||
Normalize column names in the request to match canonical dataset names.
|
||||
@@ -297,6 +303,8 @@ class ValidationPipeline:
|
||||
request: The validated chart generation request
|
||||
dataset_context: Pre-fetched dataset context to avoid duplicate
|
||||
DB queries. If None, fetches from the database.
|
||||
typed_config: Pre-parsed typed ChartConfig. If None, parses from
|
||||
request.config dict.
|
||||
|
||||
Returns:
|
||||
A new request with normalized column names
|
||||
@@ -304,8 +312,9 @@ class ValidationPipeline:
|
||||
try:
|
||||
from .dataset_validator import DatasetValidator
|
||||
|
||||
config = typed_config or parse_chart_config(request.config)
|
||||
normalized_config = DatasetValidator.normalize_column_names(
|
||||
request.config,
|
||||
config,
|
||||
request.dataset_id,
|
||||
dataset_context=dataset_context,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,8 @@ import sqlalchemy as sa
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.inspection import inspect
|
||||
|
||||
from superset.mcp_service.constants import ModelType
|
||||
|
||||
|
||||
class ColumnMetadata(BaseModel):
|
||||
"""Metadata for a selectable column."""
|
||||
@@ -52,7 +54,7 @@ class ModelSchemaInfo(BaseModel):
|
||||
- Default values for each
|
||||
"""
|
||||
|
||||
model_type: Literal["chart", "dataset", "dashboard"] = Field(
|
||||
model_type: ModelType = Field(
|
||||
..., description="The model type this schema describes"
|
||||
)
|
||||
select_columns: list[ColumnMetadata] = Field(
|
||||
@@ -82,9 +84,7 @@ class ModelSchemaInfo(BaseModel):
|
||||
class GetSchemaRequest(BaseModel):
|
||||
"""Request schema for unified get_schema tool."""
|
||||
|
||||
model_type: Literal["chart", "dataset", "dashboard"] = Field(
|
||||
..., description="Model type to get schema for"
|
||||
)
|
||||
model_type: ModelType = Field(..., description="Model type to get schema for")
|
||||
|
||||
|
||||
class GetSchemaResponse(BaseModel):
|
||||
@@ -180,6 +180,7 @@ def get_columns_from_model(
|
||||
model_cls: Type[Any],
|
||||
default_columns: list[str],
|
||||
extra_columns: dict[str, ColumnMetadata] | None = None,
|
||||
exclude_columns: set[str] | None = None,
|
||||
) -> list[ColumnMetadata]:
|
||||
"""
|
||||
Dynamically extract column metadata from a SQLAlchemy model.
|
||||
@@ -188,6 +189,7 @@ def get_columns_from_model(
|
||||
model_cls: The SQLAlchemy model class to inspect
|
||||
default_columns: List of column names that should be marked as defaults
|
||||
extra_columns: Additional columns not on the model (e.g., computed fields)
|
||||
exclude_columns: Column names to omit (e.g., sensitive fields)
|
||||
|
||||
Returns:
|
||||
List of ColumnMetadata objects for all columns
|
||||
@@ -197,6 +199,8 @@ def get_columns_from_model(
|
||||
|
||||
for col in mapper.columns:
|
||||
col_name = col.key
|
||||
if exclude_columns and col_name in exclude_columns:
|
||||
continue
|
||||
col_type = _get_sqlalchemy_type_name(col.type)
|
||||
# Get description from column doc, comment, or fallback mapping
|
||||
description = (
|
||||
@@ -234,7 +238,17 @@ def get_columns_from_model(
|
||||
# - Extra columns (computed/relationship fields not on the model)
|
||||
|
||||
# Chart configuration
|
||||
CHART_DEFAULT_COLUMNS = ["id", "slice_name", "viz_type", "url", "changed_on_humanized"]
|
||||
CHART_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"slice_name",
|
||||
"viz_type",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
CHART_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"slice_name",
|
||||
@@ -302,6 +316,18 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"certified_by": ColumnMetadata(
|
||||
name="certified_by",
|
||||
description="Name of the person who certified this chart",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"certification_details": ColumnMetadata(
|
||||
name="certification_details",
|
||||
description="Certification details or reason",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"tags": ColumnMetadata(
|
||||
name="tags", description="Chart tags", type="list", is_default=False
|
||||
),
|
||||
@@ -311,7 +337,16 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
}
|
||||
|
||||
# Dataset configuration
|
||||
DATASET_DEFAULT_COLUMNS = ["id", "table_name", "schema", "changed_on_humanized"]
|
||||
DATASET_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
DATASET_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
@@ -363,6 +398,18 @@ DATASET_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"certified_by": ColumnMetadata(
|
||||
name="certified_by",
|
||||
description="Name of the person who certified this dataset",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"certification_details": ColumnMetadata(
|
||||
name="certification_details",
|
||||
description="Certification details or reason",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"metrics": ColumnMetadata(
|
||||
name="metrics",
|
||||
description="Dataset metrics definitions",
|
||||
@@ -388,7 +435,11 @@ DASHBOARD_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
DASHBOARD_SORTABLE_COLUMNS = [
|
||||
@@ -452,6 +503,68 @@ DASHBOARD_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
}
|
||||
|
||||
|
||||
# Database configuration
|
||||
DATABASE_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"database_name",
|
||||
"backend",
|
||||
"expose_in_sqllab",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
DATABASE_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"database_name",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
DATABASE_SEARCH_COLUMNS = ["database_name"]
|
||||
DATABASE_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
"backend": ColumnMetadata(
|
||||
name="backend",
|
||||
description="Database backend type (e.g., postgresql, mysql)",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"changed_by": ColumnMetadata(
|
||||
name="changed_by",
|
||||
description="Last modifier username",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"changed_by_name": ColumnMetadata(
|
||||
name="changed_by_name",
|
||||
description="Last modifier display name",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"changed_on_humanized": ColumnMetadata(
|
||||
name="changed_on_humanized",
|
||||
description="Humanized modification time",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"created_by": ColumnMetadata(
|
||||
name="created_by",
|
||||
description="Creator username",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"created_by_name": ColumnMetadata(
|
||||
name="created_by_name",
|
||||
description="Creator display name",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"created_on_humanized": ColumnMetadata(
|
||||
name="created_on_humanized",
|
||||
description="Humanized creation time",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_chart_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for Chart model dynamically."""
|
||||
from superset.models.slice import Slice
|
||||
@@ -477,6 +590,27 @@ def get_dashboard_columns() -> list[ColumnMetadata]:
|
||||
)
|
||||
|
||||
|
||||
# Sensitive columns that should not be exposed via schema discovery
|
||||
DATABASE_EXCLUDE_COLUMNS = {
|
||||
"sqlalchemy_uri",
|
||||
"password",
|
||||
"encrypted_extra",
|
||||
"server_cert",
|
||||
}
|
||||
|
||||
|
||||
def get_database_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for Database model dynamically."""
|
||||
from superset.models.core import Database
|
||||
|
||||
return get_columns_from_model(
|
||||
Database,
|
||||
DATABASE_DEFAULT_COLUMNS,
|
||||
DATABASE_EXTRA_COLUMNS,
|
||||
exclude_columns=DATABASE_EXCLUDE_COLUMNS,
|
||||
)
|
||||
|
||||
|
||||
def get_all_column_names(columns: list[ColumnMetadata]) -> list[str]:
|
||||
"""Extract all column names from column metadata list."""
|
||||
return [col.name for col in columns]
|
||||
@@ -487,3 +621,4 @@ def get_all_column_names(columns: list[ColumnMetadata]) -> list[str]:
|
||||
CHART_ALL_COLUMNS: list[str] = []
|
||||
DATASET_ALL_COLUMNS: list[str] = []
|
||||
DASHBOARD_ALL_COLUMNS: list[str] = []
|
||||
DATABASE_ALL_COLUMNS: list[str] = []
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
# under the License.
|
||||
"""Constants for the MCP service."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
# Supported model types for schema discovery and MCP tools
|
||||
ModelType = Literal["chart", "dataset", "dashboard", "database"]
|
||||
|
||||
# Pagination defaults
|
||||
DEFAULT_PAGE_SIZE = 10 # Default number of items per page
|
||||
MAX_PAGE_SIZE = 100 # Maximum allowed page_size to prevent oversized responses
|
||||
|
||||
@@ -48,6 +48,9 @@ DEFAULT_DASHBOARD_COLUMNS = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,364 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Pydantic schemas for database-related responses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
import humanize
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.common.cache_schemas import MetadataCacheControl
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
class DatabaseFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for database listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal[
|
||||
"database_name",
|
||||
"expose_in_sqllab",
|
||||
"allow_file_upload",
|
||||
"created_by_fk",
|
||||
"changed_by_fk",
|
||||
] = Field(
|
||||
...,
|
||||
description="Column to filter on. Use get_schema(model_type='database') for "
|
||||
"available filter columns. Use created_by_fk with the user "
|
||||
"ID from get_instance_info's current_user to find "
|
||||
"databases created by a specific user.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use. Use get_schema(model_type='database') for "
|
||||
"available operators.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class DatabaseInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Database ID")
|
||||
uuid: str | None = Field(None, description="Database UUID")
|
||||
database_name: str | None = Field(None, description="Database connection name")
|
||||
backend: str | None = Field(None, description="Database backend (e.g., postgresql)")
|
||||
expose_in_sqllab: bool | None = Field(
|
||||
None, description="Whether exposed in SQL Lab"
|
||||
)
|
||||
allow_ctas: bool | None = Field(
|
||||
None, description="Whether CREATE TABLE AS is allowed"
|
||||
)
|
||||
allow_cvas: bool | None = Field(
|
||||
None, description="Whether CREATE VIEW AS is allowed"
|
||||
)
|
||||
allow_dml: bool | None = Field(
|
||||
None, description="Whether DML statements are allowed"
|
||||
)
|
||||
allow_file_upload: bool | None = Field(
|
||||
None, description="Whether file upload is allowed"
|
||||
)
|
||||
allow_run_async: bool | None = Field(
|
||||
None, description="Whether async query execution is allowed"
|
||||
)
|
||||
cache_timeout: int | None = Field(
|
||||
None, description="Cache timeout override in seconds"
|
||||
)
|
||||
configuration_method: str | None = Field(
|
||||
None, description="Configuration method (sqlalchemy_form or dynamic_form)"
|
||||
)
|
||||
force_ctas_schema: str | None = Field(
|
||||
None, description="Schema to force for CTAS queries"
|
||||
)
|
||||
impersonate_user: bool | None = Field(
|
||||
None, description="Whether to impersonate the logged-in user"
|
||||
)
|
||||
is_managed_externally: bool | None = Field(
|
||||
None, description="Whether managed by an external system"
|
||||
)
|
||||
external_url: str | None = Field(
|
||||
None, description="URL of the external management system"
|
||||
)
|
||||
extra: Dict[str, Any | None] | None = Field(None, description="Extra configuration")
|
||||
changed_by: str | None = Field(None, description="Last modifier (username)")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
changed_on_humanized: str | None = Field(
|
||||
None, description="Humanized modification time"
|
||||
)
|
||||
created_by: str | None = Field(None, description="Database creator (username)")
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
created_on_humanized: str | None = Field(
|
||||
None, description="Humanized creation time"
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap", when_used="json")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
"""Filter fields based on serialization context.
|
||||
|
||||
If context contains 'select_columns', only include those fields.
|
||||
Otherwise, include all fields (default behavior).
|
||||
"""
|
||||
data = serializer(self)
|
||||
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class DatabaseList(BaseModel):
|
||||
databases: List[DatabaseInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each database",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[DatabaseFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListDatabasesRequest(MetadataCacheControl):
|
||||
"""Request schema for list_databases with clear, unambiguous types."""
|
||||
|
||||
filters: Annotated[
|
||||
List[DatabaseFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Each "
|
||||
"filter is an object with 'col', 'opr', and 'value' "
|
||||
"properties. Cannot be used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against database fields. Cannot "
|
||||
"be used together with 'filters'.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(
|
||||
default="desc", description="Direction to order results ('asc' or 'desc')"
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[DatabaseFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, DatabaseFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListDatabasesRequest":
|
||||
"""Prevent using both search and filters simultaneously to avoid query
|
||||
conflicts."""
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching across multiple fields, "
|
||||
"or 'filters' for precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class DatabaseError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "DatabaseError":
|
||||
"""Create a standardized DatabaseError with timestamp."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetDatabaseInfoRequest(MetadataCacheControl):
|
||||
"""Request schema for get_database_info with support for ID or UUID."""
|
||||
|
||||
identifier: Annotated[
|
||||
int | str,
|
||||
Field(description="Database identifier - can be numeric ID or UUID string"),
|
||||
]
|
||||
|
||||
|
||||
def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
|
||||
"""Parse a field that may be stored as a JSON string into a dict."""
|
||||
value = getattr(obj, field_name, None)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
"""Convert a datetime to a humanized string like '2 hours ago'."""
|
||||
if dt is None:
|
||||
return None
|
||||
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
|
||||
return humanize.naturaltime(now - dt)
|
||||
|
||||
|
||||
def _get_backend(database: Any) -> str | None:
|
||||
"""Safely get backend from a Database object or row proxy.
|
||||
|
||||
backend is a @property that decrypts sqlalchemy_uri, which fails on
|
||||
row proxies returned by column-only DAO list queries. Fall back to None
|
||||
when the property raises.
|
||||
"""
|
||||
try:
|
||||
return database.backend
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def serialize_database_object(database: Any) -> DatabaseInfo | None:
|
||||
if not database:
|
||||
return None
|
||||
|
||||
return DatabaseInfo(
|
||||
id=getattr(database, "id", None),
|
||||
uuid=str(getattr(database, "uuid", ""))
|
||||
if getattr(database, "uuid", None)
|
||||
else None,
|
||||
database_name=getattr(database, "database_name", None),
|
||||
backend=_get_backend(database),
|
||||
expose_in_sqllab=getattr(database, "expose_in_sqllab", None),
|
||||
allow_ctas=getattr(database, "allow_ctas", None),
|
||||
allow_cvas=getattr(database, "allow_cvas", None),
|
||||
allow_dml=getattr(database, "allow_dml", None),
|
||||
allow_file_upload=getattr(database, "allow_file_upload", None),
|
||||
allow_run_async=getattr(database, "allow_run_async", None),
|
||||
cache_timeout=getattr(database, "cache_timeout", None),
|
||||
configuration_method=getattr(database, "configuration_method", None),
|
||||
force_ctas_schema=getattr(database, "force_ctas_schema", None),
|
||||
impersonate_user=getattr(database, "impersonate_user", None),
|
||||
is_managed_externally=getattr(database, "is_managed_externally", None),
|
||||
external_url=getattr(database, "external_url", None),
|
||||
extra=_parse_json_field(database, "extra"),
|
||||
changed_by=getattr(database, "changed_by_name", None)
|
||||
or (
|
||||
str(database.changed_by) if getattr(database, "changed_by", None) else None
|
||||
),
|
||||
changed_on=getattr(database, "changed_on", None),
|
||||
changed_on_humanized=_humanize_timestamp(getattr(database, "changed_on", None)),
|
||||
created_by=getattr(database, "created_by_name", None)
|
||||
or (
|
||||
str(database.created_by) if getattr(database, "created_by", None) else None
|
||||
),
|
||||
created_on=getattr(database, "created_on", None),
|
||||
created_on_humanized=_humanize_timestamp(getattr(database, "created_on", None)),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_database_info import get_database_info
|
||||
from .list_databases import list_databases
|
||||
|
||||
__all__ = [
|
||||
"list_databases",
|
||||
"get_database_info",
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Get database info FastMCP tool
|
||||
|
||||
This module contains the FastMCP tool for getting detailed information
|
||||
about a specific database connection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.database.schemas import (
|
||||
DatabaseError,
|
||||
DatabaseInfo,
|
||||
GetDatabaseInfoRequest,
|
||||
serialize_database_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Database",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get database info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_database_info(
|
||||
request: GetDatabaseInfoRequest, ctx: Context
|
||||
) -> DatabaseInfo | DatabaseError:
|
||||
"""Get database connection metadata by ID or UUID.
|
||||
|
||||
Returns database configuration including backend type and capabilities
|
||||
(allow_ctas, allow_dml, expose_in_sqllab, etc.).
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
- Use numeric ID (e.g., 123) or UUID string (e.g., "a1b2c3d4-...")
|
||||
- To find a database ID, use the list_databases tool first
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 1
|
||||
}
|
||||
```
|
||||
|
||||
Or with UUID:
|
||||
```json
|
||||
{
|
||||
"identifier": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving database information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Metadata cache settings: use_cache=%s refresh_metadata=%s force_refresh=%s"
|
||||
% (
|
||||
request.use_cache,
|
||||
request.refresh_metadata,
|
||||
request.force_refresh,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.database import DatabaseDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_database_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=DatabaseDAO,
|
||||
output_schema=DatabaseInfo,
|
||||
error_schema=DatabaseError,
|
||||
serializer=serialize_database_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, DatabaseInfo):
|
||||
await ctx.info(
|
||||
"Database information retrieved successfully: "
|
||||
"database_id=%s, database_name=%s, backend=%s"
|
||||
% (
|
||||
result.id,
|
||||
result.database_name,
|
||||
result.backend,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Database retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Database information retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s"
|
||||
% (
|
||||
request.identifier,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
return DatabaseError(
|
||||
error=f"Failed to get database info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
List databases FastMCP tool (Advanced with metadata cache control)
|
||||
|
||||
This module contains the FastMCP tool for listing databases using
|
||||
advanced filtering with clear, unambiguous request schema and metadata cache control.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Database
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.database.schemas import (
|
||||
DatabaseFilter,
|
||||
DatabaseInfo,
|
||||
DatabaseList,
|
||||
ListDatabasesRequest,
|
||||
serialize_database_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Database",
|
||||
annotations=ToolAnnotations(
|
||||
title="List databases",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_databases(request: ListDatabasesRequest, ctx: Context) -> DatabaseList:
|
||||
"""List database connections with filtering and search.
|
||||
|
||||
Returns database metadata including name, backend type, and permissions.
|
||||
|
||||
Sortable columns for order_column: id, database_name, changed_on,
|
||||
created_on
|
||||
"""
|
||||
await ctx.info(
|
||||
"Listing databases: page=%s, page_size=%s, search=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
request.search,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Database listing parameters: filters=%s, order_column=%s, "
|
||||
"order_direction=%s, select_columns=%s"
|
||||
% (
|
||||
request.filters,
|
||||
request.order_column,
|
||||
request.order_direction,
|
||||
request.select_columns,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Metadata cache settings: use_cache=%s, refresh_metadata=%s, force_refresh=%s"
|
||||
% (
|
||||
request.use_cache,
|
||||
request.refresh_metadata,
|
||||
request.force_refresh,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DATABASE_DEFAULT_COLUMNS,
|
||||
DATABASE_SORTABLE_COLUMNS,
|
||||
get_all_column_names,
|
||||
get_database_columns,
|
||||
)
|
||||
|
||||
# Get all column names dynamically from the model
|
||||
all_columns = get_all_column_names(get_database_columns())
|
||||
|
||||
def _serialize_database(
|
||||
obj: "Database | None", cols: list[str] | None
|
||||
) -> DatabaseInfo | None:
|
||||
"""Serialize database (filtering via model_serializer)."""
|
||||
return serialize_database_object(obj)
|
||||
|
||||
# Create tool with standard serialization
|
||||
list_tool = ModelListCore(
|
||||
dao_class=DatabaseDAO,
|
||||
output_schema=DatabaseInfo,
|
||||
item_serializer=_serialize_database,
|
||||
filter_type=DatabaseFilter,
|
||||
default_columns=DATABASE_DEFAULT_COLUMNS,
|
||||
search_columns=["database_name"],
|
||||
list_field_name="databases",
|
||||
output_list_schema=DatabaseList,
|
||||
all_columns=all_columns,
|
||||
sortable_columns=DATABASE_SORTABLE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_databases.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Databases listed successfully: count=%s, total_count=%s, total_pages=%s"
|
||||
% (
|
||||
len(result.databases) if hasattr(result, "databases") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
getattr(result, "total_pages", None),
|
||||
)
|
||||
)
|
||||
|
||||
# Apply field filtering via serialization context
|
||||
columns_to_filter = result.columns_requested
|
||||
await ctx.debug(
|
||||
"Applying field filtering via serialization context: columns=%s"
|
||||
% (columns_to_filter,)
|
||||
)
|
||||
with event_logger.log_context(action="mcp.list_databases.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Database listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
raise
|
||||
@@ -102,6 +102,12 @@ class DatasetInfo(BaseModel):
|
||||
schema_name: str | None = Field(None, description="Schema name", alias="schema")
|
||||
database_name: str | None = Field(None, description="Database name")
|
||||
description: str | None = Field(None, description="Dataset description")
|
||||
certified_by: str | None = Field(
|
||||
None, description="Name of the person or team who certified this dataset"
|
||||
)
|
||||
certification_details: str | None = Field(
|
||||
None, description="Certification details or reason"
|
||||
)
|
||||
changed_by: str | None = Field(None, description="Last modifier (username)")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
@@ -324,6 +330,9 @@ def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
if not dataset:
|
||||
return None
|
||||
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
|
||||
params = getattr(dataset, "params", None)
|
||||
if isinstance(params, str):
|
||||
try:
|
||||
@@ -360,6 +369,8 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
if getattr(dataset, "database", None)
|
||||
else None,
|
||||
description=getattr(dataset, "description", None),
|
||||
certified_by=getattr(dataset, "certified_by", None),
|
||||
certification_details=getattr(dataset, "certification_details", None),
|
||||
changed_by=getattr(dataset, "changed_by_name", None)
|
||||
or (str(dataset.changed_by) if getattr(dataset, "changed_by", None) else None),
|
||||
changed_on=getattr(dataset, "changed_on", None),
|
||||
@@ -387,7 +398,12 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
if getattr(dataset, "uuid", None)
|
||||
else None,
|
||||
schema_perm=getattr(dataset, "schema_perm", None),
|
||||
url=getattr(dataset, "url", None),
|
||||
url=(
|
||||
f"{get_superset_base_url()}/tablemodelview/edit/"
|
||||
f"{getattr(dataset, 'id', None)}"
|
||||
if getattr(dataset, "id", None)
|
||||
else None
|
||||
),
|
||||
sql=getattr(dataset, "sql", None),
|
||||
main_dttm_col=getattr(dataset, "main_dttm_col", None),
|
||||
offset=getattr(dataset, "offset", None),
|
||||
|
||||
@@ -48,6 +48,9 @@ DEFAULT_DATASET_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
@@ -35,6 +35,7 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
)
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
GenerateExploreLinkRequest,
|
||||
parse_chart_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,7 +90,7 @@ async def generate_explore_link(
|
||||
"""
|
||||
await ctx.info(
|
||||
"Generating explore link for dataset_id=%s, chart_type=%s"
|
||||
% (request.dataset_id, request.config.chart_type)
|
||||
% (request.dataset_id, request.config.get("chart_type", "unknown"))
|
||||
)
|
||||
await ctx.debug(
|
||||
"Configuration details: use_cache=%s, force_refresh=%s, cache_form_data=%s"
|
||||
@@ -97,6 +98,9 @@ async def generate_explore_link(
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse the raw config dict into a typed ChartConfig
|
||||
config = parse_chart_config(request.config)
|
||||
|
||||
await ctx.report_progress(1, 4, "Validating dataset exists")
|
||||
with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"):
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
@@ -138,10 +142,10 @@ async def generate_explore_link(
|
||||
)
|
||||
|
||||
normalized_config = DatasetValidator.normalize_column_names(
|
||||
request.config, request.dataset_id
|
||||
config, request.dataset_id
|
||||
)
|
||||
except (ImportError, AttributeError, KeyError, ValueError, TypeError):
|
||||
normalized_config = request.config
|
||||
normalized_config = config
|
||||
|
||||
# Map config to form_data using shared utilities
|
||||
form_data = map_config_to_form_data(
|
||||
@@ -197,7 +201,12 @@ async def generate_explore_link(
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Explore link generation failed for dataset_id=%s, chart_type=%s: %s: %s"
|
||||
% (request.dataset_id, request.config.chart_type, type(e).__name__, str(e))
|
||||
% (
|
||||
request.dataset_id,
|
||||
request.config.get("chart_type", "unknown"),
|
||||
type(e).__name__,
|
||||
str(e),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user