Compare commits

...
Author SHA1 Message Date
Maxime Beauchemin 54e6bd6597 chore: remove unnecessary curl installation from Dockerfile
Node.js healthcheck uses built-in http module, no need for curl.
2026-01-20 16:49:54 +00:00
Maxime Beauchemin 1a8bfca18e Use Node.js for node container healthcheck
Replaces /proc/net/tcp parsing with a proper Node.js-based HTTP
healthcheck similar to Flask's curl-based approach. Uses Node's built-in
http module to check if webpack dev server is responding.

This is more maintainable and portable than parsing kernel network state.
2026-01-20 16:07:38 +00:00
Maxime BeaucheminandClaude bc76ab4eec fix(docker): set FLASK_RUN_HOST for docker-compose-light networking
Fixes Flask backend connectivity in docker-compose-light setup by
setting FLASK_RUN_HOST=0.0.0.0 environment variable.

Without this variable, Flask's development server only binds to
127.0.0.1 (localhost), preventing the webpack dev server container
from proxying requests to the backend API. This caused health check
timeouts and made the application inaccessible.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 16:06:51 +00:00
Geidō 2900258e05 fix(Dashboard): Auto-apply filters with default values when extraForm… (#36927) 2026-01-19 11:04:09 +01:00
Jean Massucatto 2e29e33dd8 fix(calendar-heatmap): correct month display across timezones (#37064) 2026-01-19 12:37:40 +03:00
amaannawab923 39238ef8a9 feat(ag-grid-table): Enable Time Shift feature for AG Grid Table (#37072) 2026-01-19 14:56:05 +05:30
dependabot[bot] 476e454384 chore(deps): update @deck.gl/extensions requirement from ~9.2.2 to ~9.2.5 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#36618)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-18 15:42:50 -08:00
dependabot[bot] 4d462c76bd chore(deps-dev): bump @types/urijs from 1.19.25 to 1.19.26 in /superset-frontend (#36636)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-18 15:42:30 -08:00
dependabot[bot] a06e6eb680 chore(deps): bump googleapis from 169.0.0 to 170.1.0 in /superset-frontend (#37195)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-18 15:42:14 -08:00
dependabot[bot] cee5ce13e0 chore(deps-dev): bump @types/node from 25.0.3 to 25.0.9 in /superset-frontend (#37196)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-18 15:41:58 -08:00
dependabot[bot]andhainenber 6453980d8d chore(deps): bump react-error-boundary from 6.0.0 to 6.1.0 in /superset-frontend (#37206)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-01-18 15:40:57 -08:00
f984dca5cc fix(sunburst): make Show Total text theme-aware (#37177)
Co-authored-by: DarshCode123 <darshjain193@gmail.com>
Co-authored-by: SBIN2010 <Sbin2010@mail.ru>
2026-01-18 14:43:16 +03:00
Yousuf Ansari a77c2d550c fix(mixed-timeseries): prevent duplicate legend entries (#37217) 2026-01-18 00:33:33 +03:00
dependabot[bot] f00f7d1c18 chore(deps): bump ioredis from 5.9.0 to 5.9.2 in /superset-websocket (#37194)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-17 14:22:18 +07:00
dependabot[bot] 33ff127370 chore(deps-dev): bump baseline-browser-mapping from 2.9.9 to 2.9.14 in /superset-frontend (#37198)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-17 14:21:39 +07:00
dependabot[bot] b941be01cf chore(deps-dev): bump @types/node from 25.0.8 to 25.0.9 in /superset-websocket (#37193)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-17 13:57:21 +07:00
Richard Fogaca Nienkotter f4474b2e3e feat: Dynamic currency (#36416) 2026-01-16 21:58:41 -08:00
Amin GhadersohiandClaude Opus 4.5 896947c787 fix(mcp): restore select_columns filtering in list tools (#37213)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:35:34 -08:00
Pedro Rodrigues 4b1d92e575 feat: add droppable area to tab empty state (#37210) 2026-01-16 22:51:52 +01:00
Amin GhadersohiandClaude Opus 4.5 2bcb66c2fc refactor(mcp): simplify single metric chart type check (#37215)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:35:26 +01:00
Amin GhadersohiandClaude Opus 4.5 d0783da3e5 fix(mcp): Handle big_number charts and make semantic warnings non-blocking (#37142)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:19:06 +01:00
Amin Ghadersohi 4532ccf638 fix(mcp): push Flask app context in mcp_auth_hook for tool execution (#37190) 2026-01-16 07:45:57 -08:00
c30edaf075 feat: add tab select with save chart to dashboard (#36332)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
2026-01-16 15:55:27 +01:00
Kamil Gabryjelski 54f19856de fix: HTML detection in tables (#37171) 2026-01-16 15:39:32 +01:00
Kamil Gabryjelski ab8df1ab34 fix: Move head_custom_extra above csrf token input (#37173) 2026-01-16 15:39:17 +01:00
Alexandru Soare 9555798d37 fix(data-zoom): Data-zoom not rendered properly in Matrixify (#37134) 2026-01-16 14:55:21 +01:00
Mehmet Salih Yavuz 95c14b1fc1 chore(Matrixify): disable matrixify for incompatible viz types (#37163) 2026-01-16 11:12:05 +03:00
dependabot[bot] b142f1956f chore(deps): bump chrono-node from 2.7.8 to 2.9.0 in /superset-frontend (#36632)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 17:37:40 -08:00
dependabot[bot] e071e0c5a4 chore(deps-dev): bump eslint-plugin-prettier from 5.5.4 to 5.5.5 in /superset-frontend (#37160)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 17:37:27 -08:00
dependabot[bot] 129b8e10a2 chore(deps-dev): bump @babel/core from 7.28.3 to 7.28.6 in /superset-frontend (#37154)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 17:17:53 -08:00
dependabot[bot] 82d74d15ec chore(deps): bump react-intersection-observer from 9.16.0 to 10.0.0 in /superset-frontend (#37162)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 14:01:51 -08:00
dependabot[bot] 89380638b0 chore(deps): bump immer from 11.0.1 to 11.1.3 in /superset-frontend (#37159)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 14:01:37 -08:00
dependabot[bot] c6ad0dbd3a chore(deps-dev): bump prettier from 3.7.4 to 3.8.0 in /superset-frontend (#37156)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 14:01:23 -08:00
dependabot[bot] f69cd43bd0 chore(deps-dev): bump eslint-plugin-prettier from 5.5.4 to 5.5.5 in /docs (#37153)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 14:00:58 -08:00
dependabot[bot] 4c267b7ee2 chore(deps-dev): bump prettier from 3.7.4 to 3.8.0 in /docs (#37152)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 13:59:23 -08:00
dependabot[bot] 7f6cdc5616 chore(deps-dev): bump prettier from 3.7.4 to 3.8.0 in /superset-websocket (#37149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 13:59:06 -08:00
dependabot[bot] db61e4f62a chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.8.3 to 0.8.5 in /superset-frontend (#37054)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 13:58:52 -08:00
dependabot[bot] 68e917c3f6 chore(deps-dev): bump eslint-plugin-testing-library from 7.14.0 to 7.15.4 in /superset-frontend (#37052)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 13:58:33 -08:00
dependabot[bot] 96a3f2a187 chore(deps-dev): bump @babel/preset-react from 7.27.1 to 7.28.5 in /superset-frontend (#37047)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 13:58:15 -08:00
dependabot[bot] c867d9379f chore(deps-dev): bump ts-loader from 9.5.2 to 9.5.4 in /superset-frontend (#36999)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 13:57:32 -08:00
118 changed files with 5513 additions and 679 deletions
+16 -1
View File
@@ -111,13 +111,22 @@ services:
depends_on:
superset-init-light:
condition: service_completed_successfully
superset-node-light:
condition: service_healthy
volumes: *superset-volumes
healthcheck:
test: ["CMD-SHELL", "/app/docker/docker-healthcheck.sh"]
interval: 30s
timeout: 30s
retries: 3
start_period: 60s
environment:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
SUPERSET__SQLALCHEMY_EXAMPLES_URI: "duckdb:////app/data/examples.duckdb"
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
FLASK_RUN_HOST: 0.0.0.0
GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}
GITHUB_SHA: ${GITHUB_SHA:-}
@@ -154,6 +163,12 @@ services:
# it'll mount and watch local files and rebuild as you update them
DEV_MODE: "true"
BUILD_TRANSLATIONS: ${BUILD_TRANSLATIONS:-false}
healthcheck:
test: ["CMD-SHELL", "/app/docker/docker-healthcheck-node.sh"]
interval: 10s
timeout: 5s
retries: 3
start_period: 90s
environment:
# set this to false if you have perf issues running the npm i; npm run dev in-docker
# if you do so, you have to run this manually on the host, which should perform better!
@@ -163,7 +178,7 @@ services:
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server configuration
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# 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.
#
# Health check for webpack dev server using Node.js HTTP module
node -e "
const http = require('http');
const req = http.request({
hostname: 'localhost',
port: ${WEBPACK_DEVSERVER_PORT:-9000},
path: '/',
method: 'HEAD',
timeout: 3000
}, (res) => {
res.resume();
process.exit(0);
});
req.on('error', () => process.exit(1));
req.on('timeout', () => { req.destroy(); process.exit(1); });
req.end();
" || exit 1
+2 -2
View File
@@ -82,10 +82,10 @@
"@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.0.0",
"prettier": "^3.7.4",
"prettier": "^3.8.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.53.0",
"webpack": "^5.104.1"
+18 -18
View File
@@ -7034,13 +7034,13 @@ eslint-config-prettier@^10.1.8:
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
eslint-plugin-prettier@^5.5.3:
version "5.5.4"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz#9d61c4ea11de5af704d4edf108c82ccfa7f2e61c"
integrity sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==
eslint-plugin-prettier@^5.5.5:
version "5.5.5"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz#9eae11593faa108859c26f9a9c367d619a0769c0"
integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==
dependencies:
prettier-linter-helpers "^1.0.0"
synckit "^0.11.7"
prettier-linter-helpers "^1.0.1"
synckit "^0.11.12"
eslint-plugin-react@^7.37.5:
version "7.37.5"
@@ -11392,17 +11392,17 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier-linter-helpers@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
prettier-linter-helpers@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz#6a31f88a4bad6c7adda253de12ba4edaea80ebcd"
integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==
dependencies:
fast-diff "^1.1.2"
prettier@^3.7.4:
version "3.7.4"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f"
integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
prettier@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.0.tgz#f72cf71505133f40cfa2ef77a2668cdc558fcd69"
integrity sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==
pretty-error@^4.0.0:
version "4.0.0"
@@ -13154,10 +13154,10 @@ swr@^2.2.5:
dequal "^2.0.3"
use-sync-external-store "^1.4.0"
synckit@^0.11.7:
version "0.11.11"
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.11.tgz#c0b619cf258a97faa209155d9cd1699b5c998cb0"
integrity sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==
synckit@^0.11.12:
version "0.11.12"
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.12.tgz#abe74124264fbc00a48011b0d98bdc1cffb64a7b"
integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==
dependencies:
"@pkgr/core" "^0.2.9"
+250 -178
View File
@@ -60,7 +60,7 @@
"ag-grid-community": "34.3.1",
"ag-grid-react": "34.3.1",
"antd": "^5.26.0",
"chrono-node": "^2.7.8",
"chrono-node": "^2.9.0",
"classnames": "^2.2.5",
"content-disposition": "^1.0.1",
"d3-color": "^3.1.0",
@@ -79,8 +79,8 @@
"geostyler-openlayers-parser": "^4.3.0",
"geostyler-style": "7.5.0",
"geostyler-wfs-parser": "^2.0.3",
"googleapis": "^169.0.0",
"immer": "^11.0.1",
"googleapis": "^170.1.0",
"immer": "^11.1.3",
"interweave": "^13.1.1",
"jquery": "^3.7.1",
"js-levenshtein": "^1.1.6",
@@ -106,7 +106,7 @@
"react-dom": "^17.0.2",
"react-google-recaptcha": "^3.1.0",
"react-hot-loader": "^4.13.1",
"react-intersection-observer": "^9.16.0",
"react-intersection-observer": "^10.0.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -141,7 +141,7 @@
"@applitools/eyes-storybook": "^3.63.4",
"@babel/cli": "^7.28.6",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.28.3",
"@babel/core": "^7.28.6",
"@babel/eslint-parser": "^7.28.5",
"@babel/node": "^7.28.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
@@ -149,7 +149,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.27.1",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.28.6",
@@ -189,7 +189,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.0.3",
"@types/node": "^25.0.9",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -211,7 +211,7 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"baseline-browser-mapping": "^2.9.9",
"baseline-browser-mapping": "^2.9.14",
"cheerio": "1.1.2",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^13.0.1",
@@ -231,13 +231,13 @@
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.8.3",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.8.5",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.14.0",
"eslint-plugin-testing-library": "^7.15.4",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^11.1.5",
"fork-ts-checker-webpack-plugin": "^9.1.0",
@@ -256,7 +256,7 @@
"open-cli": "^8.0.0",
"oxlint": "^1.32.0",
"po2json": "^0.4.5",
"prettier": "3.7.4",
"prettier": "3.8.0",
"prettier-plugin-packagejson": "^2.5.20",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
@@ -272,7 +272,7 @@
"terser-webpack-plugin": "^5.3.16",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.1",
"ts-loader": "^9.5.4",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
@@ -339,20 +339,6 @@
"integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==",
"license": "MIT"
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@ant-design/colors": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
@@ -1161,9 +1147,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1171,22 +1157,22 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.3",
"@babel/parser": "^7.28.3",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -1270,13 +1256,13 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
@@ -1569,14 +1555,14 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
"integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.2"
"@babel/template": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -3123,15 +3109,15 @@
}
},
"node_modules/@babel/preset-react": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz",
"integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz",
"integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-transform-react-display-name": "^7.27.1",
"@babel/plugin-transform-react-display-name": "^7.28.0",
"@babel/plugin-transform-react-jsx": "^7.27.1",
"@babel/plugin-transform-react-jsx-development": "^7.27.1",
"@babel/plugin-transform-react-pure-annotations": "^7.27.1"
@@ -5011,9 +4997,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
@@ -7567,6 +7553,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -19178,9 +19175,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"version": "25.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -19633,9 +19630,9 @@
"license": "MIT"
},
"node_modules/@types/urijs": {
"version": "1.19.25",
"resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.25.tgz",
"integrity": "sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==",
"version": "1.19.26",
"resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz",
"integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==",
"dev": true,
"license": "MIT"
},
@@ -20061,15 +20058,15 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz",
"integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz",
"integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.46.2",
"@typescript-eslint/types": "^8.46.2",
"debug": "^4.3.4"
"@typescript-eslint/tsconfig-utils": "^8.53.0",
"@typescript-eslint/types": "^8.53.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -20083,9 +20080,9 @@
}
},
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
"integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -20096,6 +20093,31 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/project-service/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/project-service/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
@@ -20115,9 +20137,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz",
"integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz",
"integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -23330,9 +23352,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
"integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==",
"version": "2.9.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
"integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -24575,13 +24597,10 @@
"license": "MIT"
},
"node_modules/chrono-node": {
"version": "2.7.8",
"resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.7.8.tgz",
"integrity": "sha512-pzxemrTKu6jFVyAfkNxUckp9nlrmRFtr5lGrEJcVKyeKV9WSeGT78Oysazlzd/H0BdMv7EzACtJrw0pi2KODBQ==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz",
"integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.10.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
@@ -30089,14 +30108,14 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
"integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.11.7"
"prettier-linter-helpers": "^1.0.1",
"synckit": "^0.11.12"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -30119,6 +30138,22 @@
}
}
},
"node_modules/eslint-plugin-prettier/node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -30203,9 +30238,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.8.3.tgz",
"integrity": "sha512-btIWlck7X39T3aRXuWpVRJc0SyfqeFjTzcctUnDf2VyEwwAsxPPxwviyvKv5gBxx3TaKkrHUO6fjqZoHvn7XPw==",
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.8.5.tgz",
"integrity": "sha512-U5t99XQhDZUs1HE2ipQsLP5i26b4TQ9bPIui931gz2UAO0o0MKS8Wz/gIfPS+THbCQ80AhJ8T9SJz4Bh5k1iQg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -30302,14 +30337,14 @@
}
},
"node_modules/eslint-plugin-testing-library": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-7.14.0.tgz",
"integrity": "sha512-Z2c/UClULF67VAjQL8G2wKVdCpYBmFof9yuThXjyWwAlTxAq4vbROmS6OMPy6n1AGAMiaGgMIrKauARSVXeIHg==",
"version": "7.15.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-7.15.4.tgz",
"integrity": "sha512-qP0ZPWAvDrS3oxZJErUfn3SZiIzj5Zh2EWuyWxjR5Bsk84ntxpquh4D0USorfyw5MzECURQ8OcEeBQdspHatzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "^8.15.0",
"@typescript-eslint/utils": "^8.15.0"
"@typescript-eslint/scope-manager": "^8.51.0",
"@typescript-eslint/utils": "^8.51.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -30319,14 +30354,14 @@
}
},
"node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/scope-manager": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
"integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz",
"integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2"
"@typescript-eslint/types": "8.53.0",
"@typescript-eslint/visitor-keys": "8.53.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -30337,9 +30372,9 @@
}
},
"node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/types": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
"integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -30351,22 +30386,21 @@
}
},
"node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
"integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz",
"integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.46.2",
"@typescript-eslint/tsconfig-utils": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.1.0"
"@typescript-eslint/project-service": "8.53.0",
"@typescript-eslint/tsconfig-utils": "8.53.0",
"@typescript-eslint/types": "8.53.0",
"@typescript-eslint/visitor-keys": "8.53.0",
"debug": "^4.4.3",
"minimatch": "^9.0.5",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -30380,16 +30414,16 @@
}
},
"node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/utils": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz",
"integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz",
"integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2"
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
"@typescript-eslint/typescript-estree": "8.53.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -30404,13 +30438,13 @@
}
},
"node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
"integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz",
"integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/types": "8.53.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -30431,6 +30465,24 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/eslint-plugin-testing-library/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/eslint-plugin-testing-library/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
@@ -30460,10 +30512,30 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/eslint-plugin-testing-library/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-testing-library/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-testing-library/node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -33844,9 +33916,9 @@
}
},
"node_modules/googleapis": {
"version": "169.0.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-169.0.0.tgz",
"integrity": "sha512-IOGMG8tljCZSLvYgdojRu6mB10KEsK0J7X62sXXlQz9koe5BUAW+rqkY3qhQM9wXM6hVL3/Hase7XbxoMyeYiQ==",
"version": "170.1.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-170.1.0.tgz",
"integrity": "sha512-RLbc7yG6qzZqvAmGcgjvNIoZ7wpcCFxtc+HN+46etxDrlO4a8l5Cb7NxNQGhV91oRmL7mt56VoRoypAtEQEIKg==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
@@ -35192,9 +35264,9 @@
"license": "MIT"
},
"node_modules/immer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -48159,9 +48231,9 @@
}
},
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"devOptional": true,
"license": "MIT",
"bin": {
@@ -48175,9 +48247,9 @@
}
},
"node_modules/prettier-linter-helpers": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -49916,15 +49988,12 @@
"license": "MIT"
},
"node_modules/react-error-boundary": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz",
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.0.tgz",
"integrity": "sha512-02k9WQ/mUhdbXir0tC1NiMesGzRPaCsJEWU/4bcFrbY1YMZOtHShtZP6zw0SJrBWA/31H0KT9/FgdL8+sPKgHA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"peerDependencies": {
"react": ">=16.13.1"
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-google-recaptcha": {
@@ -49970,9 +50039,9 @@
}
},
"node_modules/react-intersection-observer": {
"version": "9.16.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz",
"integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.0.tgz",
"integrity": "sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
@@ -56171,14 +56240,14 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -56188,11 +56257,14 @@
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -56203,9 +56275,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -56618,9 +56690,9 @@
}
},
"node_modules/ts-loader": {
"version": "9.5.2",
"resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz",
"integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==",
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz",
"integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -61120,9 +61192,9 @@
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.28.3",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.26.3",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@emotion/styled": "^11.14.1",
"@testing-library/dom": "^8.20.1",
@@ -63896,7 +63968,7 @@
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.5.0",
"react-error-boundary": "^6.0.0",
"react-error-boundary": "^6.1.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
@@ -63920,7 +63992,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.23",
"@types/node": "^25.0.3",
"@types/node": "^25.0.9",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -65087,14 +65159,14 @@
"react-resizable": "^3.1.3"
},
"devDependencies": {
"@babel/core": "^7.28.3",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.27.1",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@storybook/react-webpack5": "8.6.14",
"babel-loader": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"ts-loader": "^9.5.2",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3"
},
"peerDependencies": {
@@ -65997,7 +66069,7 @@
"@types/mapbox__geojson-extent": "^1.0.3",
"@types/ngeohash": "^0.6.8",
"@types/underscore": "^1.13.0",
"@types/urijs": "^1.19.25"
"@types/urijs": "^1.19.26"
},
"peerDependencies": {
"@apache-superset/core": "*",
+13 -13
View File
@@ -141,7 +141,7 @@
"ag-grid-community": "34.3.1",
"ag-grid-react": "34.3.1",
"antd": "^5.26.0",
"chrono-node": "^2.7.8",
"chrono-node": "^2.9.0",
"classnames": "^2.2.5",
"content-disposition": "^1.0.1",
"d3-color": "^3.1.0",
@@ -160,8 +160,8 @@
"geostyler-openlayers-parser": "^4.3.0",
"geostyler-style": "7.5.0",
"geostyler-wfs-parser": "^2.0.3",
"googleapis": "^169.0.0",
"immer": "^11.0.1",
"googleapis": "^170.1.0",
"immer": "^11.1.3",
"interweave": "^13.1.1",
"jquery": "^3.7.1",
"js-levenshtein": "^1.1.6",
@@ -187,7 +187,7 @@
"react-dom": "^17.0.2",
"react-google-recaptcha": "^3.1.0",
"react-hot-loader": "^4.13.1",
"react-intersection-observer": "^9.16.0",
"react-intersection-observer": "^10.0.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -222,7 +222,7 @@
"@applitools/eyes-storybook": "^3.63.4",
"@babel/cli": "^7.28.6",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.28.3",
"@babel/core": "^7.28.6",
"@babel/eslint-parser": "^7.28.5",
"@babel/node": "^7.28.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
@@ -230,7 +230,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.27.1",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.28.6",
@@ -270,7 +270,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.0.3",
"@types/node": "^25.0.9",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -292,7 +292,7 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"baseline-browser-mapping": "^2.9.9",
"baseline-browser-mapping": "^2.9.14",
"cheerio": "1.1.2",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^13.0.1",
@@ -312,13 +312,13 @@
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.8.3",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.8.5",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.14.0",
"eslint-plugin-testing-library": "^7.15.4",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^11.1.5",
"fork-ts-checker-webpack-plugin": "^9.1.0",
@@ -337,7 +337,7 @@
"open-cli": "^8.0.0",
"oxlint": "^1.32.0",
"po2json": "^0.4.5",
"prettier": "3.7.4",
"prettier": "3.8.0",
"prettier-plugin-packagejson": "^2.5.20",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
@@ -353,7 +353,7 @@
"terser-webpack-plugin": "^5.3.16",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.1",
"ts-loader": "^9.5.4",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
@@ -12,9 +12,9 @@
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.28.3",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.26.3",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"install": "^0.13.0",
"npm": "^11.7.0",
@@ -325,6 +325,9 @@ const currency_format: SharedControlConfig<'CurrencyControl'> = {
type: 'CurrencyControl',
label: t('Currency format'),
renderTrigger: true,
description: t(
"Format metrics or columns with currency symbols as prefixes or suffixes. Choose a symbol manually or use 'Auto-detect' to apply the correct symbol based on the dataset's currency code column. When multiple currencies are present, formatting falls back to neutral numbers.",
),
};
const x_axis_time_format: SharedControlConfig<
@@ -72,6 +72,7 @@ export interface Dataset {
currency_formats?: Record<string, Currency>;
verbose_map: Record<string, string>;
main_dttm_col: string;
currency_code_column?: string;
// eg. ['["ds", true]', 'ds [asc]']
order_by_choices?: [string, string][] | null;
time_grain_sqla?: [string, string][];
@@ -55,7 +55,7 @@
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"react-error-boundary": "^6.0.0",
"react-error-boundary": "^6.1.0",
"react-markdown": "^8.0.7",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -78,7 +78,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.23",
"@types/node": "^25.0.3",
"@types/node": "^25.0.9",
"@types/prop-types": "^15.7.15",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
@@ -20,8 +20,9 @@
import { t } from '@apache-superset/core';
import { SupersetTheme } from '@apache-superset/core/ui';
import { FallbackPropsWithDimension } from './SuperChart';
import { getErrorMessage } from 'react-error-boundary';
export type Props = FallbackPropsWithDimension;
export type Props = Partial<FallbackPropsWithDimension>;
export default function FallbackComponent({ error, height, width }: Props) {
return (
@@ -38,7 +39,7 @@ export default function FallbackComponent({ error, height, width }: Props) {
<div>
<b>{t('Oops! An error occurred!')}</b>
</div>
<code>{error ? error.toString() : 'Unknown Error'}</code>
<code>{error ? getErrorMessage(error) : 'Unknown Error'}</code>
</div>
</div>
);
@@ -472,6 +472,254 @@ test('should handle chartId changes', async () => {
});
});
test('should NOT refetch data when string-based renderTrigger control (zoomable) changes', async () => {
// Control panel with zoomable as a string reference (like ['zoomable'] in control panels)
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [
['zoomable'], // String reference to shared control
[
{
name: 'metrics',
config: {
renderTrigger: false,
},
},
],
],
},
],
};
(getChartControlPanelRegistry as any).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
});
const formDataWithZoom = {
...mockFormData,
zoomable: false,
};
const { rerender, getByTestId } = render(
<StatefulChart formData={formDataWithZoom} chartType="test_chart" />,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Toggle zoomable (string-based shared control with renderTrigger: true)
const updatedFormData = {
...formDataWithZoom,
zoomable: true,
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should NOT refetch data - zoomable is a renderTrigger control
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
// But should re-render with new formData
expect(getByTestId('super-chart')).toHaveTextContent(
JSON.stringify(updatedFormData),
);
});
});
test('should NOT refetch data when other string-based renderTrigger controls change', async () => {
// Test other controls in RENDER_TRIGGER_SHARED_CONTROLS set
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [
['color_scheme'], // String reference
['y_axis_format'], // String reference
['currency_format'], // String reference
['time_shift_color'], // String reference
],
},
],
};
(getChartControlPanelRegistry as any).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
});
const { rerender, getByTestId } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Change multiple string-based renderTrigger controls
const updatedFormData = {
...mockFormData,
color_scheme: 'new_scheme',
y_axis_format: '.2f',
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should NOT refetch data
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
// But should re-render
expect(getByTestId('super-chart')).toHaveTextContent(
JSON.stringify(updatedFormData),
);
});
});
test('should refetch when string control is NOT in RENDER_TRIGGER_SHARED_CONTROLS', async () => {
// Control panel with a string control that is NOT in the renderTrigger set
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [
['some_unknown_control'], // Not in RENDER_TRIGGER_SHARED_CONTROLS
],
},
],
};
(getChartControlPanelRegistry as any).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
});
const { rerender } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Change the unknown control
const updatedFormData = {
...mockFormData,
some_unknown_control: 'new_value',
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should refetch because the control is not recognized as renderTrigger
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
});
});
test('should handle mixed string and object controls correctly', async () => {
// Control panel with both string references and object definitions
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [
['zoomable'], // String reference (in RENDER_TRIGGER_SHARED_CONTROLS)
[
{
name: 'minorTicks',
config: {
renderTrigger: true,
},
},
], // Object definition
],
},
],
};
(getChartControlPanelRegistry as any).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
});
const formDataWithControls = {
...mockFormData,
zoomable: false,
minorTicks: false,
};
const { rerender, getByTestId } = render(
<StatefulChart formData={formDataWithControls} chartType="test_chart" />,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Change both string-based and object-based renderTrigger controls
const updatedFormData = {
...formDataWithControls,
zoomable: true,
minorTicks: true,
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should NOT refetch - both are renderTrigger controls
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
// But should re-render
expect(getByTestId('super-chart')).toHaveTextContent(
JSON.stringify(updatedFormData),
);
});
});
test('should refetch when mixing renderTrigger string control with non-renderTrigger change', async () => {
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [
['zoomable'], // String reference (renderTrigger)
[
{
name: 'metrics',
config: {
renderTrigger: false,
},
},
],
],
},
],
};
(getChartControlPanelRegistry as any).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
});
const formDataWithZoom = {
...mockFormData,
zoomable: false,
metrics: ['metric1'],
};
const { rerender } = render(
<StatefulChart formData={formDataWithZoom} chartType="test_chart" />,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Change both zoomable (renderTrigger) and metrics (non-renderTrigger)
const updatedFormData = {
...formDataWithZoom,
zoomable: true,
metrics: ['metric2'],
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should refetch because metrics changed (non-renderTrigger)
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
});
});
test('should display error message when HTTP request fails with Response object', async () => {
const errorBody = JSON.stringify({ message: 'Error: division by zero' });
const mockResponse = new Response(errorBody, {
@@ -37,6 +37,24 @@ import SuperChart from './SuperChart';
// Using more specific states that align with chart loading process
type LoadingState = 'uninitialized' | 'loading' | 'loaded' | 'error';
/**
* Known shared controls that have renderTrigger: true.
* These are controls defined in sharedControls that only affect rendering,
* not data fetching. When these controls change, we should re-render
* without refetching data.
*
* This list is needed because string-based control references (e.g., ['zoomable'])
* cannot be introspected for their renderTrigger property without importing
* sharedControls, which would create a circular dependency.
*/
const RENDER_TRIGGER_SHARED_CONTROLS = new Set([
'zoomable',
'color_scheme',
'time_shift_color',
'y_axis_format',
'currency_format',
]);
/**
* Helper function to determine if data should be refetched based on formData changes
* @param prevFormData Previous formData
@@ -73,7 +91,13 @@ function shouldRefetchData(
if (section.controlSetRows) {
section.controlSetRows.forEach((row: any) => {
row.forEach((control: any) => {
if (control && typeof control === 'object') {
// Handle string references to shared controls with renderTrigger
if (
typeof control === 'string' &&
RENDER_TRIGGER_SHARED_CONTROLS.has(control)
) {
renderTriggerControls.add(control);
} else if (control && typeof control === 'object') {
const controlName = control.name || control.config?.name;
if (controlName && control.config?.renderTrigger === true) {
renderTriggerControls.add(controlName);
@@ -17,12 +17,6 @@
* under the License.
*/
import { getFormattedUTCTime } from '../src/utils';
export const AUTO_CURRENCY_SYMBOL = 'AUTO';
describe('getFormattedUTCTime', () => {
it('formatted date string should equal to UTC date', () => {
const ts = 1420070400000; // 2015.01.01 00:00:00 UTC
const formattedTime = getFormattedUTCTime(ts, '%Y-%m-%d %H:%M:%S');
expect(formattedTime).toEqual('2015-01-01 00:00:00');
});
});
export const ISO_4217_REGEX = /^[A-Z]{3}$/;
@@ -20,6 +20,8 @@
import { ExtensibleFunction } from '../models';
import { getNumberFormatter, NumberFormats } from '../number-format';
import { Currency } from '../query';
import { RowData, RowDataValue } from './types';
import { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats';
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
@@ -30,7 +32,11 @@ interface CurrencyFormatterConfig {
}
interface CurrencyFormatter {
(value: number | null | undefined): string;
(
value: number | null | undefined,
rowData?: RowData,
currencyColumn?: string,
): string;
}
export const getCurrencySymbol = (currency: Partial<Currency>) =>
@@ -41,6 +47,32 @@ export const getCurrencySymbol = (currency: Partial<Currency>) =>
.formatToParts(1)
.find(x => x.type === 'currency')?.value;
export function normalizeCurrency(value: RowDataValue): string | null {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return null;
const normalized = value.trim().toUpperCase();
return ISO_4217_REGEX.test(normalized) ? normalized : null;
}
export function hasMixedCurrencies(currencies: RowDataValue[]): boolean {
let first: string | null = null;
for (const c of currencies) {
const normalized = normalizeCurrency(c);
if (normalized === null) continue;
if (first === null) {
first = normalized;
} else if (normalized !== first) {
return true;
}
}
return false;
}
class CurrencyFormatter extends ExtensibleFunction {
d3Format: string;
@@ -49,7 +81,9 @@ class CurrencyFormatter extends ExtensibleFunction {
currency: Currency;
constructor(config: CurrencyFormatterConfig) {
super((value: number) => this.format(value));
super((value: number, rowData?: RowData, currencyColumn?: string) =>
this.format(value, rowData, currencyColumn),
);
this.d3Format = config.d3Format || NumberFormats.SMART_NUMBER;
this.currency = config.currency;
this.locale = config.locale || 'en-US';
@@ -67,19 +101,59 @@ class CurrencyFormatter extends ExtensibleFunction {
return value.replace(/%/g, '');
}
format(value: number) {
format(value: number, rowData?: RowData, currencyColumn?: string): string {
const formattedValue = getNumberFormatter(this.getNormalizedD3Format())(
value,
);
if (!this.hasValidCurrency()) {
const isAutoMode = this.currency?.symbol === AUTO_CURRENCY_SYMBOL;
if (!this.hasValidCurrency() && !isAutoMode) {
return formattedValue as string;
}
// Remove % signs from formatted value for currency display
const normalizedValue = this.normalizeForCurrency(formattedValue);
if (this.currency.symbolPosition === 'prefix') {
return `${getCurrencySymbol(this.currency)} ${normalizedValue}`;
if (isAutoMode) {
if (rowData && currencyColumn && rowData[currencyColumn]) {
const rawCurrency = rowData[currencyColumn];
const normalizedCurrency = normalizeCurrency(rawCurrency);
if (normalizedCurrency) {
try {
const symbol = getCurrencySymbol({ symbol: normalizedCurrency });
if (symbol) {
if (this.currency.symbolPosition === 'prefix') {
return `${symbol} ${normalizedValue}`;
} else if (this.currency.symbolPosition === 'suffix') {
return `${normalizedValue} ${symbol}`;
}
// Unknown symbolPosition - default to suffix
return `${normalizedValue} ${symbol}`;
}
} catch {
// Invalid currency code - return value without currency symbol
return formattedValue;
}
}
}
return formattedValue;
}
try {
const symbol = getCurrencySymbol(this.currency);
if (this.currency.symbolPosition === 'prefix') {
return `${symbol} ${normalizedValue}`;
} else if (this.currency.symbolPosition === 'suffix') {
return `${normalizedValue} ${symbol}`;
}
// Unknown symbolPosition - default to suffix
return `${normalizedValue} ${symbol}`;
} catch {
// Invalid currency code - return value without currency symbol
return formattedValue;
}
return `${normalizedValue} ${getCurrencySymbol(this.currency)}`;
}
}
@@ -1,4 +1,4 @@
/*
/**
* 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
@@ -18,5 +18,11 @@
*/
export { default as CurrencyFormatter } from './CurrencyFormatter';
export * from './CurrencyFormatter';
export {
getCurrencySymbol,
normalizeCurrency,
hasMixedCurrencies,
} from './CurrencyFormatter';
export { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats';
export * from './types';
export * from './utils';
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export type RowDataValue =
| string
| number
| boolean
| Date
| bigint
| null
| undefined;
export type RowData = Record<string, RowDataValue>;
@@ -25,21 +25,104 @@ import {
QueryFormMetric,
ValueFormatter,
} from '@superset-ui/core';
import { normalizeCurrency, hasMixedCurrencies } from './CurrencyFormatter';
import { RowData, RowDataValue } from './types';
import { AUTO_CURRENCY_SYMBOL } from './CurrencyFormats';
export const analyzeCurrencyInData = (
data: RowData[],
currencyColumn: string | undefined,
): string | null => {
if (!currencyColumn || !data || data.length === 0) {
return null;
}
const currencies: RowDataValue[] = data
.map(row => row[currencyColumn])
.filter(val => val !== null && val !== undefined);
if (currencies.length === 0) {
return null;
}
if (hasMixedCurrencies(currencies)) {
return null;
}
return normalizeCurrency(currencies[0]);
};
export const resolveAutoCurrency = (
currencyFormat: Currency | undefined,
backendDetected: string | null | undefined,
data?: RowData[],
currencyCodeColumn?: string,
): Currency | undefined | null => {
if (currencyFormat?.symbol !== AUTO_CURRENCY_SYMBOL) return currencyFormat;
const detectedCurrency =
backendDetected ??
(data && currencyCodeColumn
? analyzeCurrencyInData(data, currencyCodeColumn)
: null);
if (detectedCurrency) {
return {
symbol: detectedCurrency,
symbolPosition: currencyFormat.symbolPosition,
};
}
return null; // Mixed currencies
};
const getEffectiveCurrencyFormat = (
resolvedCurrencyFormat: Currency | undefined | null,
savedFormat: Currency | undefined,
): Currency | undefined => {
if (resolvedCurrencyFormat === null) {
return undefined;
}
if (resolvedCurrencyFormat?.symbol) {
return resolvedCurrencyFormat;
}
return savedFormat;
};
export const buildCustomFormatters = (
metrics: QueryFormMetric | QueryFormMetric[] | undefined,
savedCurrencyFormats: Record<string, Currency>,
savedColumnFormats: Record<string, string>,
d3Format: string | undefined,
currencyFormat: Currency | undefined,
currencyFormat: Currency | undefined | null,
data?: RowData[],
currencyCodeColumn?: string,
) => {
const metricsArray = ensureIsArray(metrics);
let resolvedCurrencyFormat = currencyFormat;
if (
currencyFormat?.symbol === AUTO_CURRENCY_SYMBOL &&
data &&
currencyCodeColumn
) {
const detectedCurrency = analyzeCurrencyInData(data, currencyCodeColumn);
if (detectedCurrency) {
resolvedCurrencyFormat = {
symbol: detectedCurrency,
symbolPosition: currencyFormat.symbolPosition,
};
} else {
resolvedCurrencyFormat = null;
}
}
return metricsArray.reduce((acc, metric) => {
if (isSavedMetric(metric)) {
const actualD3Format = d3Format ?? savedColumnFormats[metric];
const actualCurrencyFormat = currencyFormat?.symbol
? currencyFormat
: savedCurrencyFormats[metric];
const actualCurrencyFormat = getEffectiveCurrencyFormat(
resolvedCurrencyFormat,
savedCurrencyFormats[metric],
);
return actualCurrencyFormat?.symbol
? {
...acc,
@@ -76,14 +159,40 @@ export const getValueFormatter = (
d3Format: string | undefined,
currencyFormat: Currency | undefined,
key?: string,
data?: RowData[],
currencyCodeColumn?: string,
detectedCurrency?: string | null,
) => {
let resolvedCurrencyFormat: Currency | undefined | null = currencyFormat;
if (currencyFormat?.symbol === AUTO_CURRENCY_SYMBOL) {
// Use backend-detected currency, or fallback to frontend analysis
if (detectedCurrency !== undefined) {
resolvedCurrencyFormat = detectedCurrency
? {
symbol: detectedCurrency,
symbolPosition: currencyFormat.symbolPosition,
}
: null;
} else if (data && currencyCodeColumn) {
const frontendDetected = analyzeCurrencyInData(data, currencyCodeColumn);
resolvedCurrencyFormat = frontendDetected
? {
symbol: frontendDetected,
symbolPosition: currencyFormat.symbolPosition,
}
: null;
} else {
resolvedCurrencyFormat = null;
}
}
const customFormatter = getCustomFormatter(
buildCustomFormatters(
metrics,
savedCurrencyFormats,
savedColumnFormats,
d3Format,
currencyFormat,
resolvedCurrencyFormat,
),
metrics,
key,
@@ -92,8 +201,14 @@ export const getValueFormatter = (
if (customFormatter) {
return customFormatter;
}
if (currencyFormat?.symbol) {
return new CurrencyFormatter({ currency: currencyFormat, d3Format });
if (resolvedCurrencyFormat === null) {
return getNumberFormatter(d3Format);
}
if (resolvedCurrencyFormat?.symbol) {
return new CurrencyFormatter({
currency: resolvedCurrencyFormat,
d3Format,
});
}
return getNumberFormatter(d3Format);
};
@@ -53,6 +53,7 @@ export interface Datasource {
verboseMap?: {
[key: string]: string;
};
currencyCodeColumn?: string;
}
export const DEFAULT_METRICS: Metric[] = [
@@ -77,6 +77,12 @@ export interface ChartDataResponseResult {
// TODO(hainenber): define proper type for below attributes
rejected_filters?: any[];
applied_filters?: any[];
/**
* Detected ISO 4217 currency code when AUTO mode is used.
* Returns the currency code if all filtered data contains a single currency,
* or null if multiple currencies are present.
*/
detected_currency?: string | null;
}
export interface TimeseriesChartDataResponseResult extends ChartDataResponseResult {
@@ -65,6 +65,24 @@ describe('isProbablyHTML', () => {
expect(isProbablyHTML('if x < 5 and y > 10')).toBe(false);
expect(isProbablyHTML('price < $100')).toBe(false);
});
it('should return true for all known HTML tags', () => {
expect(isProbablyHTML('<section>Content</section>')).toBe(true);
expect(isProbablyHTML('<article>Content</article>')).toBe(true);
expect(isProbablyHTML('<nav>Content</nav>')).toBe(true);
expect(isProbablyHTML('<header>Content</header>')).toBe(true);
expect(isProbablyHTML('<footer>Content</footer>')).toBe(true);
expect(isProbablyHTML('<button>Click me</button>')).toBe(true);
expect(isProbablyHTML('<form>Content</form>')).toBe(true);
expect(isProbablyHTML('<input type="text">')).toBe(true);
expect(isProbablyHTML('<textarea>Content</textarea>')).toBe(true);
expect(isProbablyHTML('<select><option>1</option></select>')).toBe(true);
expect(isProbablyHTML('<blockquote>Quote</blockquote>')).toBe(true);
expect(isProbablyHTML('<video src="video.mp4"></video>')).toBe(true);
expect(isProbablyHTML('<audio src="audio.mp3"></audio>')).toBe(true);
expect(isProbablyHTML('<canvas></canvas>')).toBe(true);
expect(isProbablyHTML('<iframe src="page.html"></iframe>')).toBe(true);
});
});
describe('sanitizeHtmlIfNeeded', () => {
@@ -52,11 +52,73 @@ export function sanitizeHtml(htmlString: string) {
return xssFilter.process(htmlString);
}
export function hasHtmlTagPattern(str: string): boolean {
const htmlTagPattern =
/<(html|head|body|div|span|a|p|h[1-6]|title|meta|link|script|style)/i;
const KNOWN_HTML_TAGS = new Set([
'div',
'span',
'p',
'a',
'b',
'i',
'u',
'em',
'strong',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'tr',
'td',
'th',
'tbody',
'thead',
'tfoot',
'ul',
'ol',
'li',
'img',
'br',
'hr',
'pre',
'code',
'blockquote',
'section',
'article',
'nav',
'header',
'footer',
'form',
'input',
'button',
'select',
'option',
'textarea',
'label',
'fieldset',
'legend',
'video',
'audio',
'canvas',
'iframe',
'script',
'style',
'link',
'meta',
'title',
'html',
'head',
'body',
]);
return htmlTagPattern.test(str);
const HTML_TAG_PATTERN = new RegExp(
`<(${Array.from(KNOWN_HTML_TAGS).join('|')})\\b`,
'i',
);
export function hasHtmlTagPattern(str: string): boolean {
return HTML_TAG_PATTERN.test(str);
}
export function isProbablyHTML(text: string) {
@@ -91,64 +153,7 @@ export function isProbablyHTML(text: string) {
// This prevents strings like "<abcdef:12345>" from being treated as HTML
return elements.some(element => {
const tagName = element.tagName.toLowerCase();
// List of common HTML tags we want to recognize
const knownHtmlTags = [
'div',
'span',
'p',
'a',
'b',
'i',
'u',
'em',
'strong',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'tr',
'td',
'th',
'tbody',
'thead',
'tfoot',
'ul',
'ol',
'li',
'img',
'br',
'hr',
'pre',
'code',
'blockquote',
'section',
'article',
'nav',
'header',
'footer',
'form',
'input',
'button',
'select',
'option',
'textarea',
'label',
'fieldset',
'legend',
'video',
'audio',
'canvas',
'iframe',
'script',
'style',
'link',
'meta',
'title',
];
return knownHtmlTags.includes(tagName);
return KNOWN_HTML_TAGS.has(tagName);
});
}
@@ -19,18 +19,19 @@
import { render } from '@superset-ui/core/spec';
import '@testing-library/jest-dom';
import { FallbackProps } from 'react-error-boundary';
import FallbackComponent from '../../../src/chart/components/FallbackComponent';
import FallbackComponent, {
Props as FallbackComponentProps,
} from '../../../src/chart/components/FallbackComponent';
const setup = (props: Partial<FallbackProps> & FallbackProps['error']) =>
const setup = (props: FallbackComponentProps) =>
render(<FallbackComponent {...props} />);
const ERROR = new Error('CaffeineOverLoadException');
test('renders error only', () => {
const { getByText } = setup({ error: ERROR });
expect(getByText('Error: CaffeineOverLoadException')).toBeInTheDocument();
expect(getByText('CaffeineOverLoadException')).toBeInTheDocument();
});
test('renders when nothing is given', () => {
@@ -156,3 +156,34 @@ test('CurrencyFormatter:format', () => {
});
expect(currencyFormatterWithCurrencyD3(VALUE)).toEqual('56,100,057.0 PLN');
});
test('CurrencyFormatter AUTO mode uses row context', () => {
const formatter = new CurrencyFormatter({
currency: { symbol: 'AUTO', symbolPosition: 'prefix' },
d3Format: ',.2f',
});
const row = { currency: 'EUR' };
expect(formatter.format(1000, row, 'currency')).toContain('€');
expect(formatter.format(1000)).toBe('1,000.00');
});
test('CurrencyFormatter static mode ignores row context', () => {
const formatter = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
d3Format: ',.2f',
});
const row = { currency: 'EUR' };
expect(formatter.format(1000, row, 'currency')).toContain('$');
});
test('CurrencyFormatter gracefully handles invalid currency code', () => {
const formatter = new CurrencyFormatter({
currency: { symbol: 'INVALID_CODE', symbolPosition: 'prefix' },
d3Format: ',.2f',
});
// Should not throw, should return formatted value without currency symbol
expect(formatter.format(1000)).toBe('1,000.00');
});
@@ -0,0 +1,33 @@
/**
* 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 { analyzeCurrencyInData } from '../../src/currency-format/utils';
test('analyzeCurrencyInData returns currency code for single currency', () => {
const data = [
{ currency_code: 'USD', value: 100 },
{ currency_code: 'usd', value: 200 },
];
expect(analyzeCurrencyInData(data, 'currency_code')).toBe('USD');
});
test('analyzeCurrencyInData returns null for mixed or invalid data', () => {
expect(analyzeCurrencyInData([], 'currency_code')).toBeNull();
expect(analyzeCurrencyInData([{ c: 'USD' }], undefined)).toBeNull();
expect(analyzeCurrencyInData([{ c: 'USD' }, { c: 'EUR' }], 'c')).toBeNull();
});
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { hasMixedCurrencies } from '../../src/currency-format/CurrencyFormatter';
test('hasMixedCurrencies detects mixed vs single currency', () => {
expect(hasMixedCurrencies(['USD', 'EUR'])).toBe(true);
expect(hasMixedCurrencies(['USD', 'usd'])).toBe(false);
expect(hasMixedCurrencies(['USD'])).toBe(false);
expect(hasMixedCurrencies([])).toBe(false);
});
test('hasMixedCurrencies ignores null values', () => {
expect(hasMixedCurrencies(['USD', null, 'USD'])).toBe(false);
expect(hasMixedCurrencies(['USD', null, 'EUR'])).toBe(true);
});
@@ -0,0 +1,34 @@
/**
* 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 { normalizeCurrency } from '../../src/currency-format/CurrencyFormatter';
test('normalizeCurrency normalizes valid ISO 4217 codes', () => {
expect(normalizeCurrency('USD')).toBe('USD');
expect(normalizeCurrency('usd')).toBe('USD');
expect(normalizeCurrency(' eur ')).toBe('EUR');
});
test('normalizeCurrency returns null for invalid input', () => {
expect(normalizeCurrency(null)).toBe(null);
expect(normalizeCurrency('')).toBe(null);
expect(normalizeCurrency('$')).toBe(null);
expect(normalizeCurrency('DOLLAR')).toBe(null);
expect(normalizeCurrency('USDD')).toBe(null);
});
@@ -52,14 +52,14 @@
"react-resizable": "^3.1.3"
},
"devDependencies": {
"@babel/core": "^7.28.3",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.27.1",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@storybook/react-webpack5": "8.6.14",
"babel-loader": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"ts-loader": "^9.5.2",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3"
},
"peerDependencies": {
@@ -22,6 +22,7 @@ import { select as d3Select } from 'd3-selection';
import { getSequentialSchemeRegistry } from '@superset-ui/core';
import { t } from '@apache-superset/core/ui';
import CalHeatMap from './vendor/cal-heatmap';
import { convertUTCTimestampToLocal } from './utils';
const propTypes = {
data: PropTypes.shape({
@@ -105,7 +106,7 @@ function Calendar(element, props) {
const cal = new CalHeatMap();
cal.init({
start: data.start,
start: convertUTCTimestampToLocal(data.start),
data: timestamps,
itemSelector: calContainer.node(),
legendVerticalPosition: 'top',
@@ -19,7 +19,7 @@
import { getTimeFormatter } from '@superset-ui/core';
// Assume that given timestamp is UTC
// Cal-Heatmap provides local timestamps. We subtract the offset so that utcFormat displays the correct local date.
export const getFormattedUTCTime = (
ts: number | string,
timeFormat?: string,
@@ -28,3 +28,11 @@ export const getFormattedUTCTime = (
const offset = date.getTimezoneOffset() * 60 * 1000;
return getTimeFormatter(timeFormat)(date.getTime() - offset);
};
// The vendor library interprets timestamps as local time but the backend sends UTC timestamps.
// That's why we need to add the offset
export const convertUTCTimestampToLocal = (utcTimestamp: number): number => {
const date = new Date(utcTimestamp);
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return utcTimestamp + offsetMs;
};
@@ -0,0 +1,96 @@
/**
* 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 { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
describe('getFormattedUTCTime', () => {
it('formats local timestamp for display as UTC date', () => {
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const formattedTime = getFormattedUTCTime(
localTimestamp,
'%Y-%m-%d %H:%M:%S',
);
expect(formattedTime).toEqual('2015-01-01 00:00:00');
});
});
describe('convertUTCTimestampToLocal', () => {
it('adjusts timestamp so local Date shows UTC date', () => {
const utcTimestamp = 1704067200000;
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const adjustedDate = new Date(adjustedTimestamp);
expect(adjustedDate.getFullYear()).toEqual(2024);
expect(adjustedDate.getMonth()).toEqual(0);
expect(adjustedDate.getDate()).toEqual(1);
});
it('handles month boundaries', () => {
const utcTimestamp = 1706745600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
expect(adjustedDate.getFullYear()).toEqual(2024);
expect(adjustedDate.getMonth()).toEqual(1);
expect(adjustedDate.getDate()).toEqual(1);
});
it('handles year boundaries', () => {
const utcTimestamp = 1735689600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
expect(adjustedDate.getFullYear()).toEqual(2025);
expect(adjustedDate.getMonth()).toEqual(0);
expect(adjustedDate.getDate()).toEqual(1);
});
it('adds timezone offset to timestamp', () => {
const utcTimestamp = 1704067200000;
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const expectedOffset =
new Date(utcTimestamp).getTimezoneOffset() * 60 * 1000;
expect(adjustedTimestamp - utcTimestamp).toEqual(expectedOffset);
});
});
describe('integration', () => {
it('fixes timezone bug for CalHeatMap', () => {
const febFirst2024UTC = 1706745600000;
const adjustedDate = new Date(convertUTCTimestampToLocal(febFirst2024UTC));
expect(adjustedDate.getMonth()).toEqual(1);
expect(adjustedDate.getDate()).toEqual(1);
});
it('both functions work together to display dates correctly', () => {
const utcTimestamp = 1704067200000;
// convertUTCTimestampToLocal adjusts UTC for Cal-Heatmap (which interprets as local)
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
const calHeatmapDate = new Date(localTimestamp);
expect(calHeatmapDate.getMonth()).toEqual(0);
expect(calHeatmapDate.getDate()).toEqual(1);
// getFormattedUTCTime receives LOCAL timestamp (from Cal-Heatmap) and formats it
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
expect(formattedTime).toContain('2024-01-01');
});
});
@@ -47,7 +47,12 @@ export default function transformProps(chartProps) {
currencyFormat,
} = formData;
const { r, g, b } = colorPicker;
const { currencyFormats = {}, columnFormats = {} } = datasource;
const {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const { data, detected_currency: detectedCurrency } = queriesData[0];
const formatter = getValueFormatter(
metric,
@@ -55,12 +60,16 @@ export default function transformProps(chartProps) {
columnFormats,
yAxisFormat,
currencyFormat,
undefined, // key - not needed for single-metric charts
data,
currencyCodeColumn,
detectedCurrency,
);
return {
countryFieldtype,
entity,
data: queriesData[0].data,
data,
width,
height,
maxBubbleSize: parseInt(maxBubbleSize, 10),
@@ -26,7 +26,7 @@
"dependencies": {
"@deck.gl/aggregation-layers": "~9.2.5",
"@deck.gl/core": "~9.2.5",
"@deck.gl/extensions": "~9.2.2",
"@deck.gl/extensions": "~9.2.5",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mesh-layers": "~9.2.2",
@@ -58,7 +58,7 @@
"@types/mapbox__geojson-extent": "^1.0.3",
"@types/ngeohash": "^0.6.8",
"@types/underscore": "^1.13.0",
"@types/urijs": "^1.19.25"
"@types/urijs": "^1.19.26"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -120,6 +120,12 @@ const buildQuery: BuildQuery<TableChartFormData> = (
}
}
// Dashboard filter override - allows dashboard-level time shifts to OVERRIDE
// chart-level time shift settings (from PRs #33947 and #34014)
if (extra_form_data?.time_compare) {
timeOffsets = [extra_form_data.time_compare];
}
let temporalColumnAdded = false;
let temporalColumn = null;
@@ -43,9 +43,7 @@ import {
import { t } from '@apache-superset/core';
import {
ensureIsArray,
FeatureFlag,
isAdhocColumn,
isFeatureEnabled,
isPhysicalColumn,
validateInteger,
QueryFormColumn,
@@ -752,9 +750,7 @@ const config: ControlPanelConfig = {
showCalculationType: false,
showFullChoices: false,
}),
visibility: ({ controls }) =>
isAggMode({ controls }) &&
isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled),
visibility: isAggMode,
},
],
formDataOverrides: formData => ({
@@ -25,12 +25,10 @@ import {
DataRecord,
ensureIsArray,
extractTimegrain,
FeatureFlag,
getMetricLabel,
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
isFeatureEnabled,
NumberFormats,
QueryMode,
SMART_DATE_ID,
@@ -38,7 +36,7 @@ import {
TimeFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import { isEmpty, isEqual } from 'lodash';
import { isEmpty, isEqual, merge } from 'lodash';
import {
ConditionalFormattingConfig,
getColorFormatters,
@@ -465,7 +463,7 @@ const transformProps = (
const {
height,
width,
rawFormData: formData,
rawFormData: originalFormData,
queriesData = [],
ownState: serverPaginationData,
filterState,
@@ -474,6 +472,15 @@ const transformProps = (
theme,
} = chartProps;
// Merge extra_form_data (dashboard filter overrides) into formData
// This ensures dashboard-level settings (like time_compare) override chart-level settings
// From PRs #33947 and #34014
const formData = merge(
{},
originalFormData,
originalFormData.extra_form_data,
);
const {
include_search: includeSearch = false,
page_length: pageLength,
@@ -499,8 +506,7 @@ const transformProps = (
const isUsingTimeComparison =
!isEmpty(time_compare) &&
queryMode === QueryMode.Aggregate &&
comparison_type === ComparisonType.Values &&
isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled);
comparison_type === ComparisonType.Values;
const nonCustomNorInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift !== 'custom' && shift !== 'inherit',
@@ -82,7 +82,11 @@ export default function transformProps(chartProps: ChartProps) {
height,
formData,
queriesData,
datasource: { currencyFormats = {}, columnFormats = {} },
datasource: {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
},
} = chartProps;
const {
boldText,
@@ -100,7 +104,8 @@ export default function transformProps(chartProps: ChartProps) {
subtitleFontSize,
columnConfig = {},
} = formData;
const { data: dataA = [] } = queriesData[0];
const { data: dataA = [], detected_currency: detectedCurrency } =
queriesData[0] || {};
const data = dataA;
const metricName = metric ? getMetricLabel(metric) : '';
const metrics = chartProps.datasource?.metrics || [];
@@ -162,6 +167,10 @@ export default function transformProps(chartProps: ChartProps) {
columnFormats,
metricEntry?.d3format || yAxisFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
const compTitles = {
@@ -42,7 +42,11 @@ export default function transformProps(
formData,
rawFormData,
hooks,
datasource: { currencyFormats = {}, columnFormats = {} },
datasource: {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
},
theme,
} = chartProps;
const {
@@ -60,7 +64,11 @@ export default function transformProps(
subheaderFontSize,
} = formData;
const refs: Refs = {};
const { data = [], coltypes = [] } = queriesData[0] || {};
const {
data = [],
coltypes = [],
detected_currency: detectedCurrency,
} = queriesData[0] || {};
const granularity = extractTimegrain(rawFormData as QueryFormData);
const metrics = chartProps.datasource?.metrics || [];
const originalLabel = getOriginalLabel(metric, metrics);
@@ -92,6 +100,10 @@ export default function transformProps(
columnFormats,
metricEntry?.d3format || yAxisFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
const headerFormatter =
@@ -83,7 +83,11 @@ export default function transformProps(
hooks,
inContextMenu,
theme,
datasource: { currencyFormats = {}, columnFormats = {} },
datasource: {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
},
} = chartProps;
const {
colorPicker,
@@ -117,6 +121,7 @@ export default function transformProps(
coltypes = [],
from_dttm: fromDatetime,
to_dttm: toDatetime,
detected_currency: detectedCurrency,
} = queriesData[0];
const aggregatedQueryData = queriesData.length > 1 ? queriesData[1] : null;
@@ -259,6 +264,10 @@ export default function transformProps(
columnFormats,
metricEntry?.d3format || yAxisFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
const xAxisFormatter = getXAxisFormatter(timeFormat);
const yAxisFormatter =
@@ -31,7 +31,7 @@ import { ColorFormatters } from '@superset-ui/chart-controls';
import { BaseChartProps, Refs } from '../types';
export interface BigNumberDatum {
[key: string]: number | null;
[key: string]: number | string | null;
}
export type BigNumberTotalFormData = QueryFormData & {
@@ -99,6 +99,7 @@ export default function transformProps(
datasource,
} = chartProps;
const data: DataRecord[] = queriesData[0].data || [];
const detectedCurrency = queriesData[0]?.detected_currency;
const coltypeMapping = getColtypesMapping(queriesData[0]);
const {
colorScheme,
@@ -127,7 +128,11 @@ export default function transformProps(
...DEFAULT_FUNNEL_FORM_DATA,
...formData,
};
const { currencyFormats = {}, columnFormats = {} } = datasource;
const {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const refs: Refs = {};
const metricLabel = getMetricLabel(metric);
const groupbyLabels = groupby.map(getColumnLabel);
@@ -154,6 +159,10 @@ export default function transformProps(
columnFormats,
numberFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
const transformedData: {
@@ -112,6 +112,7 @@ export default function transformProps(
verboseMap = {},
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const {
groupby,
@@ -139,6 +140,7 @@ export default function transformProps(
}: EchartsGaugeFormData = { ...DEFAULT_GAUGE_FORM_DATA, ...formData };
const refs: Refs = {};
const data = (queriesData[0]?.data || []) as DataRecord[];
const detectedCurrency = queriesData[0]?.detected_currency;
const coltypeMapping = getColtypesMapping(queriesData[0]);
const numberFormatter = getValueFormatter(
metric,
@@ -146,6 +148,10 @@ export default function transformProps(
columnFormats,
numberFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const axisLineWidth = calculateAxisLineWidth(data, fontSize, overlap);
@@ -201,8 +201,17 @@ export default function transformProps(
const xAxisLabel = getColumnLabel(xAxis);
// groupby is overridden to be a single value
const yAxisLabel = getColumnLabel(groupby as unknown as QueryFormColumn);
const { data, colnames, coltypes } = queriesData[0];
const { columnFormats = {}, currencyFormats = {} } = datasource;
const {
data,
colnames,
coltypes,
detected_currency: detectedCurrency,
} = queriesData[0];
const {
columnFormats = {},
currencyFormats = {},
currencyCodeColumn,
} = datasource;
const colorColumn = normalized ? 'rank' : metricLabel;
const colors = getSequentialSchemeRegistry().get(linearColorScheme)?.colors;
const getAxisFormatter =
@@ -225,6 +234,10 @@ export default function transformProps(
columnFormats,
yAxisFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
let [min, max] = (valueBounds || []).map(parseAxisBound);
@@ -36,6 +36,7 @@ import {
isTimeseriesAnnotationLayer,
QueryFormData,
QueryFormMetric,
resolveAutoCurrency,
TimeseriesChartDataResponseResult,
TimeseriesDataRecord,
tooltipHtml,
@@ -138,10 +139,11 @@ export default function transformProps(
verboseMap = {},
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const { label_map: labelMap } =
const { label_map: labelMap, detected_currency: backendDetectedCurrency } =
queriesData[0] as TimeseriesChartDataResponseResult;
const { label_map: labelMapB } =
const { label_map: labelMapB, detected_currency: backendDetectedCurrencyB } =
queriesData[1] as TimeseriesChartDataResponseResult;
const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[];
const data2 = (queriesData[1].data || []) as TimeseriesDataRecord[];
@@ -244,8 +246,10 @@ export default function transformProps(
},
);
const MetricDisplayNameA = getMetricDisplayName(metrics[0], verboseMap);
const MetricDisplayNameB = getMetricDisplayName(metricsB[0], verboseMap);
const MetricDisplayNameA: string =
getMetricDisplayName(metrics[0], verboseMap) || '';
const MetricDisplayNameB: string =
getMetricDisplayName(metricsB[0], verboseMap) || '';
const dataTypes = getColtypesMapping(queriesData[0]);
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
@@ -279,20 +283,34 @@ export default function transformProps(
xAxisType,
});
const series: SeriesOption[] = [];
const resolvedCurrency = resolveAutoCurrency(
currencyFormat,
backendDetectedCurrency,
data1,
currencyCodeColumn,
);
const resolvedCurrencySecondary = resolveAutoCurrency(
currencyFormatSecondary,
backendDetectedCurrencyB,
data2,
currencyCodeColumn,
);
const formatter = contributionMode
? getNumberFormatter(',.0%')
: currencyFormat?.symbol
: resolvedCurrency?.symbol
? new CurrencyFormatter({
d3Format: yAxisFormat,
currency: currencyFormat,
currency: resolvedCurrency,
})
: getNumberFormatter(yAxisFormat);
const formatterSecondary = contributionMode
? getNumberFormatter(',.0%')
: currencyFormatSecondary?.symbol
: resolvedCurrencySecondary?.symbol
? new CurrencyFormatter({
d3Format: yAxisFormatSecondary,
currency: currencyFormatSecondary,
currency: resolvedCurrencySecondary,
})
: getNumberFormatter(yAxisFormatSecondary);
const customFormatters = buildCustomFormatters(
@@ -300,14 +318,18 @@ export default function transformProps(
currencyFormats,
columnFormats,
yAxisFormat,
currencyFormat,
resolvedCurrency,
data1,
currencyCodeColumn,
);
const customFormattersSecondary = buildCustomFormatters(
[...ensureIsArray(metrics), ...ensureIsArray(metricsB)],
currencyFormats,
columnFormats,
yAxisFormatSecondary,
currencyFormatSecondary,
resolvedCurrencySecondary,
data2,
currencyCodeColumn,
);
const primarySeries = new Set<string>();
@@ -400,10 +422,12 @@ export default function transformProps(
if (groupby.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
const metricPart: string = showQueryIdentifiers
? `${MetricDisplayNameA} (Query A)`
: MetricDisplayNameA;
displayName = `${metricPart}, ${entryName}`;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
@@ -471,10 +495,12 @@ export default function transformProps(
if (groupbyB.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
const metricPart: string = showQueryIdentifiers
? `${MetricDisplayNameB} (Query B)`
: MetricDisplayNameB;
displayName = `${metricPart}, ${entryName}`;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
@@ -135,8 +135,13 @@ export default function transformProps(
emitCrossFilters,
datasource,
} = chartProps;
const { columnFormats = {}, currencyFormats = {} } = datasource;
const { data: rawData = [] } = queriesData[0];
const {
columnFormats = {},
currencyFormats = {},
currencyCodeColumn,
} = datasource;
const { data: rawData = [], detected_currency: detectedCurrency } =
queriesData[0];
const coltypeMapping = getColtypesMapping(queriesData[0]);
const {
@@ -181,6 +186,10 @@ export default function transformProps(
columnFormats,
numberFormat,
currencyFormat,
undefined,
rawData,
currencyCodeColumn,
detectedCurrency,
);
let data = rawData;
@@ -170,7 +170,7 @@ export default function transformProps(
emitCrossFilters,
datasource,
} = chartProps;
const { data = [] } = queriesData[0];
const { data = [], detected_currency: detectedCurrency } = queriesData[0];
const coltypeMapping = getColtypesMapping(queriesData[0]);
const {
groupby = [],
@@ -192,6 +192,7 @@ export default function transformProps(
currencyFormats = {},
columnFormats = {},
verboseMap = {},
currencyCodeColumn,
} = datasource;
const refs: Refs = {};
const primaryValueFormatter = getValueFormatter(
@@ -200,6 +201,10 @@ export default function transformProps(
columnFormats,
numberFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
const secondaryValueFormatter = secondaryMetric
? getValueFormatter(
@@ -208,6 +213,10 @@ export default function transformProps(
columnFormats,
numberFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
)
: undefined;
@@ -380,6 +389,7 @@ export default function transformProps(
text: t('Total: %s', primaryValueFormatter(totalValue)),
fontSize: 16,
fontWeight: 'bold',
fill: theme.colorText,
},
z: 10,
}
@@ -37,6 +37,7 @@ import {
isIntervalAnnotationLayer,
isPhysicalColumn,
isTimeseriesAnnotationLayer,
resolveAutoCurrency,
TimeseriesChartDataResponseResult,
NumberFormats,
} from '@superset-ui/core';
@@ -134,10 +135,14 @@ export default function transformProps(
verboseMap = {},
columnFormats = {},
currencyFormats = {},
currencyCodeColumn,
} = datasource;
const [queryData] = queriesData;
const { data = [], label_map = {} } =
queryData as TimeseriesChartDataResponseResult;
const {
data = [],
label_map = {},
detected_currency: backendDetectedCurrency,
} = queryData as TimeseriesChartDataResponseResult;
const dataTypes = getColtypesMapping(queryData);
const annotationData = getAnnotationData(chartProps);
@@ -275,15 +280,29 @@ export default function transformProps(
const percentFormatter = forcePercentFormatter
? getPercentFormatter(yAxisFormat)
: getPercentFormatter(NumberFormats.PERCENT_2_POINT);
const defaultFormatter = currencyFormat?.symbol
? new CurrencyFormatter({ d3Format: yAxisFormat, currency: currencyFormat })
// Resolve currency for AUTO mode (backend detection takes precedence)
const resolvedCurrency = resolveAutoCurrency(
currencyFormat,
backendDetectedCurrency,
data,
currencyCodeColumn,
);
const defaultFormatter = resolvedCurrency?.symbol
? new CurrencyFormatter({
d3Format: yAxisFormat,
currency: resolvedCurrency,
})
: getNumberFormatter(yAxisFormat);
const customFormatters = buildCustomFormatters(
metrics,
currencyFormats,
columnFormats,
yAxisFormat,
currencyFormat,
resolvedCurrency,
data,
currencyCodeColumn,
);
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
@@ -119,8 +119,12 @@ export default function transformProps(
emitCrossFilters,
datasource,
} = chartProps;
const { data = [] } = queriesData[0];
const { columnFormats = {}, currencyFormats = {} } = datasource;
const { data = [], detected_currency: detectedCurrency } = queriesData[0];
const {
columnFormats = {},
currencyFormats = {},
currencyCodeColumn,
} = datasource;
const { setDataMask = () => {}, onContextMenu } = hooks;
const coltypeMapping = getColtypesMapping(queriesData[0]);
const BORDER_COLOR = theme.colorBgBase;
@@ -150,6 +154,10 @@ export default function transformProps(
columnFormats,
numberFormat,
currencyFormat,
undefined,
data,
currencyCodeColumn,
detectedCurrency,
);
const formatter = (params: TreemapSeriesCallbackDataParams) =>
@@ -492,3 +492,59 @@ describe('BigNumberWithTrendline - Aggregation Tests', () => {
expect(transformed.bigNumber).toStrictEqual(10);
});
});
test('BigNumberWithTrendline AUTO mode should detect single currency', () => {
const props = generateProps(
[
{ __timestamp: 1607558400000, value: 1000, currency_code: 'USD' },
{ __timestamp: 1607558500000, value: 2000, currency_code: 'USD' },
],
{
yAxisFormat: ',.2f',
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
},
);
props.datasource.currencyCodeColumn = 'currency_code';
const transformed = transformProps(props);
// The headerFormatter should include $ for USD
expect(transformed.headerFormatter(1000)).toContain('$');
});
test('BigNumberWithTrendline AUTO mode should use neutral formatting for mixed currencies', () => {
const props = generateProps(
[
{ __timestamp: 1607558400000, value: 1000, currency_code: 'USD' },
{ __timestamp: 1607558500000, value: 2000, currency_code: 'EUR' },
],
{
yAxisFormat: ',.2f',
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
},
);
props.datasource.currencyCodeColumn = 'currency_code';
const transformed = transformProps(props);
// With mixed currencies, should not show currency symbol
const formatted = transformed.headerFormatter(1000);
expect(formatted).not.toContain('$');
expect(formatted).not.toContain('€');
});
test('BigNumberWithTrendline should preserve static currency format', () => {
const props = generateProps(
[
{ __timestamp: 1607558400000, value: 1000, currency_code: 'USD' },
{ __timestamp: 1607558500000, value: 2000, currency_code: 'EUR' },
],
{
yAxisFormat: ',.2f',
currencyFormat: { symbol: 'GBP', symbolPosition: 'prefix' },
},
);
props.datasource.currencyCodeColumn = 'currency_code';
const transformed = transformProps(props);
// Static mode should always show £
expect(transformed.headerFormatter(1000)).toContain('£');
});
@@ -31,6 +31,20 @@ import { supersetTheme } from '@apache-superset/core/ui';
import { EchartsTimeseriesChartProps } from '../../src/types';
import transformProps from '../../src/Timeseries/transformProps';
type YAxisFormatter = (value: number, index: number) => string;
function getYAxisFormatter(
transformed: ReturnType<typeof transformProps>,
): YAxisFormatter {
const yAxis = transformed.echartOptions.yAxis as {
axisLabel?: { formatter?: YAxisFormatter };
};
expect(yAxis).toBeDefined();
expect(yAxis.axisLabel).toBeDefined();
expect(yAxis.axisLabel?.formatter).toBeDefined();
return yAxis.axisLabel!.formatter!;
}
const formData: SqlaFormData = {
colorScheme: 'bnbColors',
datasource: '3__table',
@@ -723,3 +737,121 @@ describe('legend sorting', () => {
]);
});
});
test('EchartsTimeseries AUTO mode should detect single currency and format with $ for USD', () => {
const chartProps = new ChartProps<SqlaFormData>({
...chartPropsConfig,
formData: {
...formData,
metrics: ['sum__num'],
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
},
datasource: {
currencyCodeColumn: 'currency_code',
columnFormats: {},
currencyFormats: {},
verboseMap: {},
},
queriesData: [
{
data: [
{
'San Francisco': 1000,
__timestamp: 599616000000,
currency_code: 'USD',
},
{
'San Francisco': 2000,
__timestamp: 599916000000,
currency_code: 'USD',
},
],
},
],
});
const transformed = transformProps(chartProps as EchartsTimeseriesChartProps);
const formatter = getYAxisFormatter(transformed);
expect(formatter(1000, 0)).toContain('$');
});
test('EchartsTimeseries AUTO mode should use neutral formatting for mixed currencies', () => {
const chartProps = new ChartProps<SqlaFormData>({
...chartPropsConfig,
formData: {
...formData,
metrics: ['sum__num'],
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
},
datasource: {
currencyCodeColumn: 'currency_code',
columnFormats: {},
currencyFormats: {},
verboseMap: {},
},
queriesData: [
{
data: [
{
'San Francisco': 1000,
__timestamp: 599616000000,
currency_code: 'USD',
},
{
'San Francisco': 2000,
__timestamp: 599916000000,
currency_code: 'EUR',
},
],
},
],
});
const transformed = transformProps(chartProps as EchartsTimeseriesChartProps);
// With mixed currencies, Y-axis should use neutral formatting
const formatter = getYAxisFormatter(transformed);
const formatted = formatter(1000, 0);
expect(formatted).not.toContain('$');
expect(formatted).not.toContain('€');
});
test('EchartsTimeseries should preserve static currency format with £ for GBP', () => {
const chartProps = new ChartProps<SqlaFormData>({
...chartPropsConfig,
formData: {
...formData,
metrics: ['sum__num'],
currencyFormat: { symbol: 'GBP', symbolPosition: 'prefix' },
},
datasource: {
currencyCodeColumn: 'currency_code',
columnFormats: {},
currencyFormats: {},
verboseMap: {},
},
queriesData: [
{
data: [
{
'San Francisco': 1000,
__timestamp: 599616000000,
currency_code: 'USD',
},
{
'San Francisco': 2000,
__timestamp: 599916000000,
currency_code: 'EUR',
},
],
},
],
});
const transformed = transformProps(chartProps as EchartsTimeseriesChartProps);
// Static mode should always show £
const formatter = getYAxisFormatter(transformed);
expect(formatter(1000, 0)).toContain('£');
});
@@ -22,15 +22,18 @@ import { t } from '@apache-superset/core';
import {
AdhocMetric,
BinaryQueryObjectFilterClause,
Currency,
CurrencyFormatter,
DataRecordValue,
FeatureFlag,
getColumnLabel,
getNumberFormatter,
getSelectedText,
hasMixedCurrencies,
isAdhocColumn,
isFeatureEnabled,
isPhysicalColumn,
normalizeCurrency,
NumberFormatter,
} from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
@@ -101,6 +104,71 @@ const StyledMinusSquareOutlined = styled(MinusSquareOutlined)`
stroke-width: 16px;
`;
/** Aggregator with currency tracking support */
interface CurrencyTrackingAggregator {
getCurrencies?: () => string[];
}
type BaseFormatter = NumberFormatter | CurrencyFormatter;
/** Create formatter that handles AUTO mode with per-cell currency detection */
const createCurrencyAwareFormatter = (
baseFormatter: BaseFormatter,
currencyConfig: Currency | undefined,
d3Format: string,
fallbackCurrency?: string,
): ((value: number, aggregator?: CurrencyTrackingAggregator) => string) => {
const isAutoMode = currencyConfig?.symbol === 'AUTO';
return (value: number, aggregator?: CurrencyTrackingAggregator): string => {
// If not AUTO mode, use base formatter directly
if (!isAutoMode) {
return baseFormatter(value);
}
// AUTO mode: check aggregator for currency tracking
if (aggregator && typeof aggregator.getCurrencies === 'function') {
const currencies = aggregator.getCurrencies();
if (currencies && currencies.length > 0) {
if (hasMixedCurrencies(currencies)) {
return getNumberFormatter(d3Format)(value);
}
const detectedCurrency = normalizeCurrency(currencies[0]);
if (detectedCurrency && currencyConfig) {
const cellFormatter = new CurrencyFormatter({
currency: {
symbol: detectedCurrency,
symbolPosition: currencyConfig.symbolPosition,
},
d3Format,
});
return cellFormatter(value);
}
}
}
// Fallback: use detected_currency from API response if available
if (fallbackCurrency && currencyConfig) {
const normalizedFallback = normalizeCurrency(fallbackCurrency);
if (normalizedFallback) {
const fallbackFormatter = new CurrencyFormatter({
currency: {
symbol: normalizedFallback,
symbolPosition: currencyConfig.symbolPosition,
},
d3Format,
});
return fallbackFormatter(value);
}
}
// Final fallback to neutral format
return getNumberFormatter(d3Format)(value);
};
};
const aggregatorsFactory = (formatter: NumberFormatter) => ({
Count: aggregatorTemplates.count(formatter),
'Count Unique Values': aggregatorTemplates.countUnique(formatter),
@@ -171,6 +239,8 @@ export default function PivotTableChart(props: PivotTableProps) {
rowSubTotals,
valueFormat,
currencyFormat,
currencyCodeColumn,
detectedCurrency,
emitCrossFilters,
setDataMask,
selectedFilters,
@@ -186,9 +256,11 @@ export default function PivotTableChart(props: PivotTableProps) {
} = props;
const theme = useTheme();
const defaultFormatter = useMemo(
// Base formatter without currency-awareness (for non-AUTO mode or as fallback)
const baseFormatter = useMemo(
() =>
currencyFormat?.symbol
currencyFormat?.symbol && currencyFormat.symbol !== 'AUTO'
? new CurrencyFormatter({
currency: currencyFormat,
d3Format: valueFormat,
@@ -196,6 +268,18 @@ export default function PivotTableChart(props: PivotTableProps) {
: getNumberFormatter(valueFormat),
[valueFormat, currencyFormat],
);
// Currency-aware formatter for AUTO mode support
const defaultFormatter = useMemo(
() =>
createCurrencyAwareFormatter(
baseFormatter,
currencyFormat,
valueFormat,
detectedCurrency ?? undefined,
),
[baseFormatter, currencyFormat, valueFormat, detectedCurrency],
);
const customFormatsArray = useMemo(
() =>
Array.from(
@@ -216,19 +300,31 @@ export default function PivotTableChart(props: PivotTableProps) {
hasCustomMetricFormatters
? {
[METRIC_KEY]: Object.fromEntries(
customFormatsArray.map(([metric, d3Format, currency]) => [
metric,
currency
? new CurrencyFormatter({
currency,
d3Format,
})
: getNumberFormatter(d3Format),
]),
customFormatsArray.map(([metric, d3Format, currency]) => {
// Create base formatter
const metricBaseFormatter =
currency && (currency as Currency).symbol !== 'AUTO'
? new CurrencyFormatter({
currency: currency as Currency,
d3Format: d3Format as string,
})
: getNumberFormatter(d3Format as string);
// Wrap with currency-aware formatter for AUTO mode support
return [
metric,
createCurrencyAwareFormatter(
metricBaseFormatter,
currency as Currency | undefined,
d3Format as string,
detectedCurrency ?? undefined,
),
];
}),
),
}
: undefined,
[customFormatsArray, hasCustomMetricFormatters],
[customFormatsArray, hasCustomMetricFormatters, detectedCurrency],
);
const metricNames = useMemo(
@@ -249,12 +345,14 @@ export default function PivotTableChart(props: PivotTableProps) {
...record,
[METRIC_KEY]: name,
value: record[name],
// Mark currency column for per-cell currency detection in aggregators
__currencyColumn: currencyCodeColumn,
}))
.filter(record => record.value !== null),
],
[],
),
[data, metricNames],
[data, metricNames, currencyCodeColumn],
);
const groupbyRows = useMemo(
() => groupbyRowsRaw.map(getColumnLabel),
@@ -79,11 +79,21 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
rawFormData,
hooks: { setDataMask = () => {}, onContextMenu },
filterState,
datasource: { verboseMap = {}, columnFormats = {}, currencyFormats = {} },
datasource: {
verboseMap = {},
columnFormats = {},
currencyFormats = {},
currencyCodeColumn,
},
emitCrossFilters,
theme,
} = chartProps;
const { data, colnames, coltypes } = queriesData[0];
const {
data,
colnames,
coltypes,
detected_currency: detectedCurrency,
} = queriesData[0];
const {
groupbyRows,
groupbyColumns,
@@ -148,6 +158,8 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
theme,
);
// AUTO symbol passed through - PivotTableChart handles per-cell currency detection
return {
width,
height,
@@ -169,6 +181,8 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
rowSubTotals,
valueFormat,
currencyFormat,
currencyCodeColumn,
detectedCurrency,
emitCrossFilters,
setDataMask,
selectedFilters,
@@ -749,7 +749,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
style={style}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), allowRenderHtml)}
</td>
);
});
@@ -766,7 +766,7 @@ export class TableRenderer extends Component {
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), allowRenderHtml)}
</td>
);
}
@@ -830,7 +830,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
</td>
);
});
@@ -847,7 +847,7 @@ export class TableRenderer extends Component {
onClick={grandTotalCallback}
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
</td>
);
}
@@ -186,9 +186,13 @@ const usFmtPct = numberFormat({
suffix: '%',
});
const fmtNonString = formatter => x =>
typeof x === 'string' ? x : formatter(x);
const fmtNonString = formatter => (x, aggregator) =>
typeof x === 'string' ? x : formatter(x, aggregator);
/*
* Aggregators track currencies via push() and expose them via getCurrencies()
* for per-cell currency detection in AUTO mode.
*/
const baseAggregatorTemplates = {
count(formatter = usFmtInt) {
return () =>
@@ -211,14 +215,21 @@ const baseAggregatorTemplates = {
return function () {
return {
uniq: [],
currencySet: new Set(),
push(record) {
if (!Array.from(this.uniq).includes(record[attr])) {
this.uniq.push(record[attr]);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return fn(this.uniq);
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -231,16 +242,23 @@ const baseAggregatorTemplates = {
return function () {
return {
sum: 0,
currencySet: new Set(),
push(record) {
if (Number.isNaN(Number(record[attr]))) {
this.sum = record[attr];
} else {
this.sum += parseFloat(record[attr]);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return this.sum;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -253,6 +271,7 @@ const baseAggregatorTemplates = {
return function (data) {
return {
val: null,
currencySet: new Set(),
sorter: getSort(
typeof data !== 'undefined' ? data.sorters : null,
attr,
@@ -285,10 +304,16 @@ const baseAggregatorTemplates = {
) {
this.val = x;
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return this.val;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format(x) {
if (typeof x === 'number') {
return formatter(x);
@@ -307,6 +332,7 @@ const baseAggregatorTemplates = {
return {
vals: [],
strMap: {},
currencySet: new Set(),
push(record) {
const val = record[attr];
const x = Number(val);
@@ -316,6 +342,9 @@ const baseAggregatorTemplates = {
} else {
this.vals.push(x);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
if (
@@ -339,6 +368,9 @@ const baseAggregatorTemplates = {
const i = (this.vals.length - 1) * q;
return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -354,11 +386,15 @@ const baseAggregatorTemplates = {
m: 0.0,
s: 0.0,
strValue: null,
currencySet: new Set(),
push(record) {
const x = Number(record[attr]);
if (Number.isNaN(x)) {
this.strValue =
typeof record[attr] === 'string' ? record[attr] : this.strValue;
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
return;
}
this.n += 1.0;
@@ -368,6 +404,9 @@ const baseAggregatorTemplates = {
const mNew = this.m + (x - this.m) / this.n;
this.s += (x - this.m) * (x - mNew);
this.m = mNew;
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
if (this.strValue) {
@@ -392,6 +431,9 @@ const baseAggregatorTemplates = {
throw new Error('unknown mode for runningStat');
}
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -405,6 +447,7 @@ const baseAggregatorTemplates = {
return {
sumNum: 0,
sumDenom: 0,
currencySet: new Set(),
push(record) {
if (!Number.isNaN(Number(record[num]))) {
this.sumNum += parseFloat(record[num]);
@@ -412,10 +455,16 @@ const baseAggregatorTemplates = {
if (!Number.isNaN(Number(record[denom]))) {
this.sumDenom += parseFloat(record[denom]);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return this.sumNum / this.sumDenom;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: formatter,
numInputs:
typeof num !== 'undefined' && typeof denom !== 'undefined' ? 0 : 2,
@@ -447,6 +496,9 @@ const baseAggregatorTemplates = {
return this.inner.value() / acc;
},
getCurrencies() {
return this.inner.getCurrencies ? this.inner.getCurrencies() : [];
},
numInputs: wrapped(...Array.from(x || []))().numInputs,
};
};
@@ -68,6 +68,8 @@ interface PivotTableCustomizeProps {
rowSubTotals: boolean;
valueFormat: string;
currencyFormat: Currency;
currencyCodeColumn?: string;
detectedCurrency?: string | null;
setDataMask: SetDataMaskHook;
emitCrossFilters?: boolean;
selectedFilters?: SelectedFiltersType;
@@ -96,4 +96,193 @@ describe('PivotTableChart transformProps', () => {
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
});
});
describe('Per-cell currency detection (AUTO mode passes through)', () => {
it('should pass AUTO mode through for per-cell detection (single currency data)', () => {
const autoFormData = {
...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
};
const autoChartProps = new ChartProps<QueryFormData>({
formData: autoFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', currency: 'USD', revenue: 100 },
{ country: 'Canada', currency: 'USD', revenue: 200 },
{ country: 'Mexico', currency: 'usd', revenue: 150 },
],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps);
// AUTO mode should be preserved for per-cell detection in PivotTableChart
expect(result.currencyFormat).toEqual({
symbol: 'AUTO',
symbolPosition: 'prefix',
});
// currencyCodeColumn should be passed through for per-cell detection
expect(result.currencyCodeColumn).toBe('currency');
});
it('should pass AUTO mode through for per-cell detection (mixed currency data)', () => {
const autoFormData = {
...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
};
const autoChartProps = new ChartProps<QueryFormData>({
formData: autoFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', currency: 'USD', revenue: 100 },
{ country: 'UK', currency: 'GBP', revenue: 200 },
{ country: 'France', currency: 'EUR', revenue: 150 },
],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps);
// AUTO mode should be preserved - per-cell detection happens in PivotTableChart
expect(result.currencyFormat).toEqual({
symbol: 'AUTO',
symbolPosition: 'prefix',
});
expect(result.currencyCodeColumn).toBe('currency');
});
it('should pass AUTO mode through when no currency column is defined', () => {
const autoFormData = {
...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
};
const autoChartProps = new ChartProps<QueryFormData>({
formData: autoFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', revenue: 100 },
{ country: 'UK', revenue: 200 },
],
colnames: ['country', 'revenue'],
coltypes: [1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
// No currencyCodeColumn defined
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps);
expect(result.currencyFormat).toEqual({
symbol: 'AUTO',
symbolPosition: 'prefix',
});
// currencyCodeColumn should be undefined when not configured
expect(result.currencyCodeColumn).toBeUndefined();
});
it('should handle empty data gracefully in AUTO mode', () => {
const autoFormData = {
...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
};
const autoChartProps = new ChartProps<QueryFormData>({
formData: autoFormData,
width: 800,
height: 600,
queriesData: [
{
data: [],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps);
expect(result.currencyFormat).toEqual({
symbol: 'AUTO',
symbolPosition: 'prefix',
});
expect(result.currencyCodeColumn).toBe('currency');
});
it('should preserve static currency format when not using AUTO mode', () => {
const staticFormData = {
...formData,
currencyFormat: { symbol: 'EUR', symbolPosition: 'suffix' },
};
const staticChartProps = new ChartProps<QueryFormData>({
formData: staticFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', currency: 'USD', revenue: 100 },
{ country: 'UK', currency: 'GBP', revenue: 200 },
],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(staticChartProps);
expect(result.currencyFormat).toEqual({
symbol: 'EUR',
symbolPosition: 'suffix',
});
});
});
});
@@ -892,7 +892,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
columnKey: key,
accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => {
const [isHtml, text] = formatColumnValue(column, value);
const [isHtml, text] = formatColumnValue(column, value, row.original);
const html = isHtml && allowRenderHtml ? { __html: text } : undefined;
let backgroundColor;
@@ -29,6 +29,7 @@ import {
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
normalizeCurrency,
NumberFormats,
QueryMode,
SMART_DATE_ID,
@@ -200,7 +201,12 @@ const processColumns = memoizeOne(function processColumns(
props: TableChartProps,
) {
const {
datasource: { columnFormats, currencyFormats, verboseMap },
datasource: {
columnFormats,
currencyFormats,
verboseMap,
currencyCodeColumn,
},
rawFormData: {
table_timestamp_format: tableTimestampFormat,
metrics: metrics_,
@@ -210,7 +216,12 @@ const processColumns = memoizeOne(function processColumns(
queriesData,
} = props;
const granularity = extractTimegrain(props.rawFormData);
const { data: records, colnames, coltypes } = queriesData[0] || {};
const {
data: records,
colnames,
coltypes,
detected_currency: detectedCurrency,
} = queriesData[0] || {};
// convert `metrics` and `percentMetrics` to the key names in `data.records`
const metrics = (metrics_ ?? []).map(getMetricLabel);
const rawPercentMetrics = (percentMetrics_ ?? []).map(getMetricLabel);
@@ -276,10 +287,25 @@ const processColumns = memoizeOne(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && (numberFormat || currency))) {
formatter = currency?.symbol
// Resolve AUTO currency when currency column isn't in query results
let resolvedCurrency = currency;
if (
currency?.symbol === 'AUTO' &&
detectedCurrency &&
(!currencyCodeColumn || !colnames?.includes(currencyCodeColumn))
) {
const normalizedCurrency = normalizeCurrency(detectedCurrency);
if (normalizedCurrency) {
resolvedCurrency = {
...currency,
symbol: normalizedCurrency,
};
}
}
formatter = resolvedCurrency?.symbol
? new CurrencyFormatter({
d3Format: numberFormat,
currency,
currency: resolvedCurrency,
})
: getNumberFormatter(numberFormat);
}
@@ -292,6 +318,7 @@ const processColumns = memoizeOne(function processColumns(
isPercentMetric,
formatter,
config,
currencyCodeColumn,
};
});
return [metrics, percentMetrics, columns] as [
@@ -72,6 +72,7 @@ export interface DataColumnMeta {
isNumeric?: boolean;
config?: TableColumnConfig;
isChildColumn?: boolean;
currencyCodeColumn?: string;
}
export interface TableChartData {
@@ -33,6 +33,8 @@ import DateWithFormatter from './DateWithFormatter';
function formatValue(
formatter: DataColumnMeta['formatter'],
value: DataRecordValue,
rowData?: Record<string, DataRecordValue>,
currencyColumn?: string,
): [boolean, string] {
// render undefined as empty string
if (value === undefined) {
@@ -48,6 +50,10 @@ function formatValue(
return [false, 'N/A'];
}
if (formatter) {
// If formatter is a CurrencyFormatter, pass row context for AUTO mode
if (formatter instanceof CurrencyFormatter) {
return [false, formatter(value as number, rowData, currencyColumn)];
}
return [false, formatter(value as number)];
}
if (typeof value === 'string') {
@@ -59,8 +65,9 @@ function formatValue(
export function formatColumnValue(
column: DataColumnMeta,
value: DataRecordValue,
rowData?: Record<string, DataRecordValue>,
) {
const { dataType, formatter, config = {} } = column;
const { dataType, formatter, config = {}, currencyCodeColumn } = column;
const isNumber = dataType === GenericDataType.Numeric;
const smallNumberFormatter =
config.d3SmallNumberFormat === undefined
@@ -76,5 +83,7 @@ export function formatColumnValue(
? smallNumberFormatter
: formatter,
value,
rowData,
currencyCodeColumn,
);
}
@@ -28,6 +28,7 @@ export default function isEqualColumns(
return (
a.datasource.columnFormats === b.datasource.columnFormats &&
a.datasource.currencyFormats === b.datasource.currencyFormats &&
a.datasource.currencyCodeColumn === b.datasource.currencyCodeColumn &&
a.datasource.verboseMap === b.datasource.verboseMap &&
a.formData.tableTimestampFormat === b.formData.tableTimestampFormat &&
a.formData.timeGrainSqla === b.formData.timeGrainSqla &&
@@ -36,6 +37,8 @@ export default function isEqualColumns(
isEqualArray(a.formData.metrics, b.formData.metrics) &&
isEqualArray(a.queriesData?.[0]?.colnames, b.queriesData?.[0]?.colnames) &&
isEqualArray(a.queriesData?.[0]?.coltypes, b.queriesData?.[0]?.coltypes) &&
a.queriesData?.[0]?.detected_currency ===
b.queriesData?.[0]?.detected_currency &&
JSON.stringify(a.formData.extraFilters || null) ===
JSON.stringify(b.formData.extraFilters || null) &&
JSON.stringify(a.formData.extraFormData || null) ===
@@ -0,0 +1,155 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CurrencyFormatter, getNumberFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import { formatColumnValue } from '../../src/utils/formatValue';
import { DataColumnMeta } from '../../src/types';
test('formatColumnValue with CurrencyFormatter AUTO mode uses row context', () => {
const formatter = new CurrencyFormatter({
d3Format: ',.2f',
currency: { symbol: 'AUTO', symbolPosition: 'prefix' },
});
const column: DataColumnMeta = {
key: 'revenue',
label: 'Revenue',
dataType: GenericDataType.Numeric,
formatter,
isNumeric: true,
currencyCodeColumn: 'currency_code',
};
const rowData = { revenue: 1000, currency_code: 'EUR' };
const [isHtml, result] = formatColumnValue(column, 1000, rowData);
expect(isHtml).toBe(false);
expect(result).toContain('€');
expect(result).toContain('1,000.00');
});
test('formatColumnValue with CurrencyFormatter AUTO mode returns neutral format without row context', () => {
const formatter = new CurrencyFormatter({
d3Format: ',.2f',
currency: { symbol: 'AUTO', symbolPosition: 'prefix' },
});
const column: DataColumnMeta = {
key: 'revenue',
label: 'Revenue',
dataType: GenericDataType.Numeric,
formatter,
isNumeric: true,
currencyCodeColumn: 'currency_code',
};
// No row data provided
const [isHtml, result] = formatColumnValue(column, 1000);
expect(isHtml).toBe(false);
expect(result).toBe('1,000.00');
expect(result).not.toContain('$');
expect(result).not.toContain('€');
});
test('formatColumnValue with static CurrencyFormatter ignores row context', () => {
const formatter = new CurrencyFormatter({
d3Format: ',.2f',
currency: { symbol: 'USD', symbolPosition: 'prefix' },
});
const column: DataColumnMeta = {
key: 'revenue',
label: 'Revenue',
dataType: GenericDataType.Numeric,
formatter,
isNumeric: true,
};
// Row has EUR but static mode should show $
const rowData = { revenue: 1000, currency_code: 'EUR' };
const [isHtml, result] = formatColumnValue(column, 1000, rowData);
expect(isHtml).toBe(false);
expect(result).toContain('$');
expect(result).not.toContain('€');
});
test('formatColumnValue with AUTO mode normalizes currency codes', () => {
const formatter = new CurrencyFormatter({
d3Format: ',.2f',
currency: { symbol: 'AUTO', symbolPosition: 'prefix' },
});
const column: DataColumnMeta = {
key: 'revenue',
label: 'Revenue',
dataType: GenericDataType.Numeric,
formatter,
isNumeric: true,
currencyCodeColumn: 'currency_code',
};
// Test lowercase currency code
const rowData1 = { revenue: 500, currency_code: 'usd' };
const [, result1] = formatColumnValue(column, 500, rowData1);
expect(result1).toContain('$');
// Test uppercase currency code (GBP -> £)
const rowData2 = { revenue: 750, currency_code: 'GBP' };
const [, result2] = formatColumnValue(column, 750, rowData2);
expect(result2).toContain('£');
});
test('formatColumnValue handles null values', () => {
const column: DataColumnMeta = {
key: 'revenue',
label: 'Revenue',
dataType: GenericDataType.Numeric,
formatter: getNumberFormatter(',.2f'),
isNumeric: true,
};
const [, nullResult] = formatColumnValue(column, null);
expect(nullResult).toBe('N/A');
});
test('formatColumnValue with small number format and currency', () => {
const formatter = new CurrencyFormatter({
d3Format: ',.2f',
currency: { symbol: 'EUR', symbolPosition: 'prefix' },
});
const column: DataColumnMeta = {
key: 'revenue',
label: 'Revenue',
dataType: GenericDataType.Numeric,
formatter,
isNumeric: true,
config: {
d3SmallNumberFormat: ',.4f',
currencyFormat: { symbol: 'EUR', symbolPosition: 'prefix' },
},
};
// Small number should use small number format
const [, result] = formatColumnValue(column, 0.5);
expect(result).toContain('€');
expect(result).toContain('0.5000');
});
@@ -119,6 +119,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
datasource.schema,
description: datasource.description,
main_dttm_col: datasource.main_dttm_col,
currency_code_column: datasource.currency_code_column ?? null,
normalize_columns: datasource.normalize_columns,
always_filter_main_dttm: datasource.always_filter_main_dttm,
offset: datasource.offset,
@@ -30,6 +30,7 @@ import {
getClientErrorObject,
getExtensionsRegistry,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import {
css,
styled,
@@ -56,8 +57,10 @@ import {
Col,
Divider,
EditableTitle,
Flex,
FormLabel,
Icons,
InfoTooltip,
Loading,
Row,
Select,
@@ -157,6 +160,31 @@ const StyledTableTabWrapper = styled.div`
}
`;
const DefaultColumnSettingsContainer = styled.div`
${({ theme }) => css`
margin-bottom: ${theme.sizeUnit * 4}px;
`}
`;
const DefaultColumnSettingsTitle = styled.h4`
${({ theme }) => css`
margin: 0 0 ${theme.sizeUnit * 2}px 0;
font-size: ${theme.fontSizeLG}px;
font-weight: ${theme.fontWeightMedium};
color: ${theme.colorText};
`}
`;
const FieldLabelWithTooltip = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextLabel};
`}
`;
const StyledButtonWrapper = styled.span`
${({ theme }) => `
margin-top: ${theme.sizeUnit * 3}px;
@@ -234,18 +262,10 @@ function ColumnCollectionTable({
'advanced_data_type',
'type',
'is_dttm',
'main_dttm_col',
'filterable',
'groupby',
]
: [
'column_name',
'type',
'is_dttm',
'main_dttm_col',
'filterable',
'groupby',
]
: ['column_name', 'type', 'is_dttm', 'filterable', 'groupby']
}
sortColumns={
isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)
@@ -254,18 +274,10 @@ function ColumnCollectionTable({
'advanced_data_type',
'type',
'is_dttm',
'main_dttm_col',
'filterable',
'groupby',
]
: [
'column_name',
'type',
'is_dttm',
'main_dttm_col',
'filterable',
'groupby',
]
: ['column_name', 'type', 'is_dttm', 'filterable', 'groupby']
}
allowDeletes
allowAddItem={allowAddItem}
@@ -403,7 +415,6 @@ function ColumnCollectionTable({
type: t('Data type'),
groupby: t('Is dimension'),
is_dttm: t('Is temporal'),
main_dttm_col: t('Default datetime'),
filterable: t('Is filterable'),
}
: {
@@ -411,7 +422,6 @@ function ColumnCollectionTable({
type: t('Data type'),
groupby: t('Is dimension'),
is_dttm: t('Is temporal'),
main_dttm_col: t('Default datetime'),
filterable: t('Is filterable'),
}
}
@@ -445,27 +455,6 @@ function ColumnCollectionTable({
{v}
</StyledLabelWrapper>
),
main_dttm_col: (value, _onItemChange, _label, record) => {
const checked = datasource.main_dttm_col === record.column_name;
const disabled = !record?.is_dttm;
return (
<Radio
aria-label={t(
'Set %s as default datetime column',
record.column_name,
)}
data-test={`radio-default-dttm-${record.column_name}`}
checked={checked}
disabled={disabled}
onChange={() =>
onDatasourceChange({
...datasource,
main_dttm_col: record.column_name,
})
}
/>
);
},
type: d => (d ? <Label>{d}</Label> : null),
advanced_data_type: d => (
<Label onChange={onColumnsChange}>{d}</Label>
@@ -497,27 +486,6 @@ function ColumnCollectionTable({
{v}
</StyledLabelWrapper>
),
main_dttm_col: (value, _onItemChange, _label, record) => {
const checked = datasource.main_dttm_col === record.column_name;
const disabled = !record?.is_dttm;
return (
<Radio
aria-label={t(
'Set %s as default datetime column',
record.column_name,
)}
data-test={`radio-default-dttm-${record.column_name}`}
checked={checked}
disabled={disabled}
onChange={() =>
onDatasourceChange({
...datasource,
main_dttm_col: record.column_name,
})
}
/>
);
},
type: d => (d ? <Label>{d}</Label> : null),
is_dttm: checkboxGenerator,
filterable: checkboxGenerator,
@@ -1083,11 +1051,12 @@ class DatasourceEditor extends PureComponent {
),
);
// validate currency code
// validate currency code (skip 'AUTO' - it's a placeholder for auto-detection)
try {
this.state.datasource.metrics?.forEach(
metric =>
metric.currency?.symbol &&
metric.currency.symbol !== 'AUTO' &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: metric.currency.symbol,
@@ -1108,6 +1077,86 @@ class DatasourceEditor extends PureComponent {
return metrics.sort(({ id: a }, { id: b }) => b - a);
}
renderDefaultColumnSettings() {
const { datasource, databaseColumns, calculatedColumns } = this.state;
const { theme } = this.props;
const allColumns = [...databaseColumns, ...calculatedColumns];
// Get datetime-compatible columns for the default datetime dropdown
const datetimeColumns = allColumns
.filter(col => col.is_dttm)
.map(col => ({
value: col.column_name,
label: col.verbose_name || col.column_name,
}));
// Get string-type columns for the currency code dropdown
const stringColumns = allColumns
.filter(col => col.type_generic === GenericDataType.String)
.map(col => ({
value: col.column_name,
label: col.verbose_name || col.column_name,
}));
return (
<DefaultColumnSettingsContainer data-test="default-column-settings">
<DefaultColumnSettingsTitle>
{t('Default Column Settings')}
</DefaultColumnSettingsTitle>
<Flex vertical gap={theme.sizeUnit * 3}>
<Flex vertical gap={theme.sizeUnit}>
<FieldLabelWithTooltip>
<span>{t('Default datetime column')}</span>
<InfoTooltip
tooltip={t(
'Sets the default temporal column for this dataset. Automatically selected as the time column when building charts that require a time dimension and used in dashboard level time filters.',
)}
/>
</FieldLabelWithTooltip>
<Select
ariaLabel={t('Default datetime column')}
options={datetimeColumns}
value={datasource.main_dttm_col}
onChange={value =>
this.onDatasourceChange({
...datasource,
main_dttm_col: value,
})
}
placeholder={t('Select datetime column')}
allowClear
data-test="default-datetime-column-select"
/>
</Flex>
<Flex vertical gap={theme.sizeUnit}>
<FieldLabelWithTooltip>
<span>{t('Currency code column')}</span>
<InfoTooltip
tooltip={t(
"Select the column containing currency codes such as USD, EUR, GBP, etc. Used when building charts when 'Auto-detect' currency formatting is enabled. If this column is not set or if a chart metric contains multiple currencies, charts will fall back to neutral numeric formatting.",
)}
/>
</FieldLabelWithTooltip>
<Select
ariaLabel={t('Currency code column')}
options={stringColumns}
value={datasource.currency_code_column}
onChange={value =>
this.onDatasourceChange({
...datasource,
currency_code_column: value,
})
}
placeholder={t('Select currency code column')}
allowClear
data-test="currency-code-column-select"
/>
</Flex>
</Flex>
</DefaultColumnSettingsContainer>
);
}
renderSettingsFieldset() {
const { datasource } = this.state;
return (
@@ -1889,6 +1938,10 @@ class DatasourceEditor extends PureComponent {
),
children: (
<StyledTableTabWrapper>
{this.renderDefaultColumnSettings()}
<DefaultColumnSettingsTitle>
{t('Column Settings')}
</DefaultColumnSettingsTitle>
<ColumnButtonWrapper>
<StyledButtonWrapper>
<Button
@@ -1926,6 +1979,10 @@ class DatasourceEditor extends PureComponent {
),
children: (
<StyledTableTabWrapper>
{this.renderDefaultColumnSettings()}
<DefaultColumnSettingsTitle>
{t('Column Settings')}
</DefaultColumnSettingsTitle>
<ColumnCollectionTable
columns={this.state.calculatedColumns}
onColumnsChange={calculatedColumns =>
@@ -365,34 +365,52 @@ test('properly updates the metric information', async () => {
});
});
test('shows the default datetime column', async () => {
test('shows the default datetime column in dropdown', async () => {
await asyncRender(createProps());
const columnsButton = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsButton);
const dsDefaultDatetimeRadio = screen.getByTestId('radio-default-dttm-ds');
expect(dsDefaultDatetimeRadio).toBeChecked();
// Find the Default Column Settings section
const defaultColumnSettings = screen.getByTestId('default-column-settings');
expect(defaultColumnSettings).toBeInTheDocument();
const genderDefaultDatetimeRadio = screen.getByTestId(
'radio-default-dttm-gender',
);
expect(genderDefaultDatetimeRadio).not.toBeChecked();
// Find the default datetime column dropdown
const defaultDatetimeDropdown = screen.getByRole('combobox', {
name: 'Default datetime column',
});
expect(defaultDatetimeDropdown).toBeInTheDocument();
// Verify the current value is 'ds' (from main_dttm_col in props)
const selectedValue = await screen.findByText('ds', {
selector: '.ant-select-selection-item',
});
expect(selectedValue).toBeInTheDocument();
});
test('allows choosing only temporal columns as the default datetime', async () => {
test('default datetime dropdown shows only temporal columns', async () => {
await asyncRender(createProps());
const columnsButton = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsButton);
const dsDefaultDatetimeRadio = screen.getByTestId('radio-default-dttm-ds');
expect(dsDefaultDatetimeRadio).toBeEnabled();
// Find the default datetime column dropdown
const defaultDatetimeDropdown = screen.getByRole('combobox', {
name: 'Default datetime column',
});
const genderDefaultDatetimeRadio = screen.getByTestId(
'radio-default-dttm-gender',
await userEvent.click(defaultDatetimeDropdown);
// Check that temporal column 'ds' is in the dropdown options
const options = document.querySelectorAll('.ant-select-item-option');
const dsOption = Array.from(options).find(o => o.textContent?.includes('ds'));
expect(dsOption).toBeDefined();
// Check that non-temporal column 'gender' is NOT in the dropdown
const genderOption = Array.from(options).find(o =>
o.textContent?.includes('gender'),
);
expect(genderDefaultDatetimeRadio).toBeDisabled();
expect(genderOption).toBeUndefined();
});
test('aborts pending requests on unmount without errors', async () => {
@@ -23,6 +23,7 @@ import {
userEvent,
selectOption,
} from 'spec/helpers/testing-library';
import { GenericDataType } from '@apache-superset/core/api/core';
import type { DatasetObject } from 'src/features/datasets/types';
import {
createProps,
@@ -136,3 +137,69 @@ test('changes currency symbol from USD to GBP', async () => {
);
expect(updatedMetric?.currency?.symbolPosition).toBe('prefix');
}, 60000);
test('currency code column dropdown shows only string columns', async () => {
const baseProps = createProps();
const testProps = {
...baseProps,
datasource: {
...baseProps.datasource,
columns: [
{
id: 100,
type: 'VARCHAR(255)',
type_generic: GenericDataType.String,
filterable: true,
is_dttm: false,
is_active: true,
expression: '',
groupby: true,
column_name: 'currency_code',
},
{
id: 101,
type: 'DECIMAL',
type_generic: GenericDataType.Numeric,
filterable: false,
is_dttm: false,
is_active: true,
expression: '',
groupby: false,
column_name: 'amount',
},
...baseProps.datasource.columns,
],
},
onChange: jest.fn(),
};
fastRender(testProps);
await dismissDatasourceWarning();
// Navigate to columns tab
const columnsTab = await screen.findByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
// Find the currency code column dropdown
const currencyCodeDropdown = await screen.findByRole('combobox', {
name: 'Currency code column',
});
await userEvent.click(currencyCodeDropdown);
// Verify STRING column is available
await waitFor(() => {
const options = document.querySelectorAll('.ant-select-item-option');
const currencyCodeOption = Array.from(options).find(o =>
o.textContent?.includes('currency_code'),
);
expect(currencyCodeOption).toBeDefined();
});
// Verify NUMERIC column is NOT available
const options = document.querySelectorAll('.ant-select-item-option');
const amountOption = Array.from(options).find(o =>
o.textContent?.includes('amount'),
);
expect(amountOption).toBeUndefined();
}, 60000);
@@ -201,30 +201,37 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
const prevChartConfig = usePrevious(chartConfiguration);
useEffect(() => {
if (!showIndicators && nativeIndicators.length > 0) {
const shouldReset =
(!chart ||
chart.chartStatus === 'failed' ||
chart.chartStatus === null) &&
nativeIndicators.length > 0;
const shouldRecalculate =
chart?.queriesResponse?.[0]?.rejected_filters !==
prevChart?.queriesResponse?.[0]?.rejected_filters ||
chart?.queriesResponse?.[0]?.applied_filters !==
prevChart?.queriesResponse?.[0]?.applied_filters ||
nativeFilters !== prevNativeFilters ||
chartLayoutItems !== prevChartLayoutItems ||
dataMask !== prevDataMask ||
prevChartConfig !== chartConfiguration;
if (shouldReset) {
setNativeIndicators(indicatorsInitialState);
} else if (prevChartStatus !== 'success') {
if (
chart?.queriesResponse?.[0]?.rejected_filters !==
prevChart?.queriesResponse?.[0]?.rejected_filters ||
chart?.queriesResponse?.[0]?.applied_filters !==
prevChart?.queriesResponse?.[0]?.applied_filters ||
nativeFilters !== prevNativeFilters ||
chartLayoutItems !== prevChartLayoutItems ||
dataMask !== prevDataMask ||
prevChartConfig !== chartConfiguration
) {
setNativeIndicators(
selectNativeIndicatorsForChart(
nativeFilters,
dataMask,
chartId,
chart,
chartLayoutItems,
chartConfiguration,
),
);
}
} else if (
showIndicators &&
(shouldRecalculate || nativeIndicators.length === 0)
) {
const newIndicators = selectNativeIndicatorsForChart(
nativeFilters,
dataMask,
chartId,
chart,
chartLayoutItems,
chartConfiguration,
);
setNativeIndicators(newIndicators);
}
}, [
chart,
@@ -27,12 +27,14 @@ import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState';
import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponent';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import AnchorLink from 'src/dashboard/components/AnchorLink';
import { Typography } from '@superset-ui/core/components/Typography';
import {
DragDroppable,
Droppable,
} from 'src/dashboard/components/dnd/DragDroppable';
import { componentShape } from 'src/dashboard/util/propShapes';
import { TAB_TYPE } from 'src/dashboard/util/componentTypes';
import { Link } from 'react-router-dom';
export const RENDER_TAB = 'RENDER_TAB';
export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT';
@@ -262,41 +264,55 @@ const Tab = props => {
</Droppable>
)}
{shouldDisplayEmptyState && (
<EmptyState
title={
editMode
? t('Drag and drop components to this tab')
: t('There are no components added to this tab')
}
description={
canEdit &&
(editMode ? (
<span>
{t('You can')}{' '}
<a
href={`/chart/add?dashboard_id=${dashboardId}`}
rel="noopener noreferrer"
target="_blank"
>
{t('create a new chart')}
</a>{' '}
{t('or use existing ones from the panel on the right')}
</span>
) : (
<span>
{t('You can add the components in the')}{' '}
<span
role="button"
tabIndex={0}
onClick={() => dispatch(setEditMode(true))}
>
{t('edit mode')}
</span>
</span>
))
}
image="chart.svg"
/>
<Droppable
component={tabComponent}
orientation="column"
index={editMode ? 1 : 0}
depth={depth}
onDrop={handleTopDropTargetDrop}
editMode={editMode}
dropToChild
>
{() => (
<div data-test="emptystate-drop-indicator">
<EmptyState
title={
editMode
? t('Drag and drop components to this tab')
: t('There are no components added to this tab')
}
description={
canEdit &&
(editMode ? (
<span>
{t('You can')}{' '}
<Typography.Link
href={`/chart/add?dashboard_id=${dashboardId}`}
rel="noopener noreferrer"
target="_blank"
>
{t('create a new chart')}
</Typography.Link>{' '}
{t('or use existing ones from the panel on the right')}
</span>
) : (
<span>
{t('You can add the components in the')}{' '}
<span
role="button"
tabIndex={0}
onClick={() => dispatch(setEditMode(true))}
>
{t('edit mode')}
</span>
</span>
))
}
image="chart.svg"
/>
</div>
)}
</Droppable>
)}
{tabComponent.children.map((componentId, componentIndex) => (
<Fragment key={componentId}>
@@ -56,9 +56,32 @@ jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
dropIndicatorProps: props.dropIndicatorProps,
}
: {};
const handleClick = () => {
if (props.onDrop) {
// Create a mock dropResult based on the component props
const dropResult = {
source: {
id: 'MARKDOWN-1',
type: 'MARKDOWN',
index: 0,
},
dragging: {
id: 'MARKDOWN-1',
type: 'MARKDOWN',
meta: {},
},
destination: {
id: props.component?.id || '',
type: props.component?.type || '',
index: props.index ?? 0,
},
};
props.onDrop(dropResult);
}
};
return (
<div>
<button type="button" data-test="MockDroppable" onClick={props.onDrop}>
<button type="button" data-test="MockDroppable" onClick={handleClick}>
DragDroppable
</button>
{props.children(childProps)}
@@ -403,6 +426,7 @@ test('Render tab content with no children, editMode: true, canEdit: true', () =>
},
},
});
expect(screen.queryByTestId('emptystate-drop-indicator')).toBeInTheDocument();
expect(
screen.getByText('Drag and drop components to this tab'),
).toBeVisible();
@@ -415,6 +439,49 @@ test('Render tab content with no children, editMode: true, canEdit: true', () =>
).toHaveAttribute('href', '/chart/add?dashboard_id=23');
});
test('Drag to empty state, editMode: true, canEdit: true', async () => {
const props = createProps();
props.editMode = true;
props.component.children = [];
const mockHandleComponentDrop = jest.fn();
props.handleComponentDrop = mockHandleComponentDrop;
render(<Tab {...props} />, {
useRedux: true,
useDnd: true,
initialState: {
dashboardInfo: {
dash_edit_perm: true,
},
},
});
const emptyStateIndicator = screen.getByTestId('emptystate-drop-indicator');
expect(emptyStateIndicator).toBeInTheDocument();
const mockDroppableButtons = screen.getAllByTestId('MockDroppable');
expect(mockDroppableButtons).toHaveLength(2);
// Click the MockDroppable button that wraps the empty state indicator (index 1)
// This simulates dropping a component on the empty state
userEvent.click(mockDroppableButtons[1]);
// Verify that handleComponentDrop was called with correct destination
await waitFor(() => {
expect(mockHandleComponentDrop).toHaveBeenCalled();
});
expect(mockHandleComponentDrop).toHaveBeenCalledWith(
expect.objectContaining({
destination: {
id: props.component.id,
index: 0,
type: 'TAB',
},
}),
);
});
test('AnchorLink renders in view mode', () => {
const props = createProps();
props.renderType = 'RENDER_TAB';
@@ -27,6 +27,7 @@ import { FilterBarOrientation } from 'src/dashboard/types';
import { FILTER_BAR_TEST_ID } from './utils';
import FilterBar from '.';
import { FILTERS_CONFIG_MODAL_TEST_ID } from '../FiltersConfigModal/FiltersConfigModal';
import * as dataMaskActions from 'src/dataMask/actions';
jest.useFakeTimers();
@@ -359,4 +360,118 @@ describe('FilterBar', () => {
const { container } = renderWrapper(openedBarProps, stateWithFilter);
expect(container).toBeInTheDocument();
});
test('auto-applies filter when extraFormData is empty in applied state', async () => {
const filterId = 'test-filter-auto-apply';
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
const stateWithIncompleteFilter = {
...stateWithoutNativeFilters,
dashboardInfo: {
id: 1,
dash_edit_perm: true,
},
dataMask: {
[filterId]: {
id: filterId,
filterState: { value: ['value1', 'value2'] },
extraFormData: {},
},
},
nativeFilters: {
filters: {
[filterId]: {
id: filterId,
name: 'Test Filter',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'test_column' } }],
defaultDataMask: {
filterState: { value: ['value1', 'value2'] },
extraFormData: {},
},
controlValues: {
enableEmptyFilter: true,
},
cascadeParentIds: [],
scope: {
rootPath: ['ROOT_ID'],
excluded: [],
},
type: 'NATIVE_FILTER',
description: '',
chartsInScope: [],
tabsInScope: [],
},
},
filtersState: {},
},
};
renderWrapper(openedBarProps, stateWithIncompleteFilter);
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument();
updateDataMaskSpy.mockRestore();
});
test('renders correctly when filter has complete extraFormData', async () => {
const filterId = 'test-filter-complete';
const stateWithCompleteFilter = {
...stateWithoutNativeFilters,
dashboardInfo: {
id: 1,
dash_edit_perm: true,
},
dataMask: {
[filterId]: {
id: filterId,
filterState: { value: ['value1'] },
extraFormData: {
filters: [{ col: 'test_column', op: 'IN', val: ['value1'] }],
},
},
},
nativeFilters: {
filters: {
[filterId]: {
id: filterId,
name: 'Test Filter',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'test_column' } }],
defaultDataMask: {
filterState: { value: ['value1'] },
extraFormData: {
filters: [{ col: 'test_column', op: 'IN', val: ['value1'] }],
},
},
controlValues: {
enableEmptyFilter: true,
},
cascadeParentIds: [],
scope: {
rootPath: ['ROOT_ID'],
excluded: [],
},
type: 'NATIVE_FILTER',
description: '',
chartsInScope: [],
tabsInScope: [],
},
},
filtersState: {},
},
};
renderWrapper(openedBarProps, stateWithCompleteFilter);
await act(async () => {
jest.advanceTimersByTime(100);
});
expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument();
});
});
@@ -213,24 +213,41 @@ const FilterBar: FC<FiltersBarProps> = ({
dataMask: Partial<DataMask>,
) => {
setDataMaskSelected(draft => {
const isFirstTimeInitialization =
!initializedFilters.has(filter.id) &&
dataMaskSelectedRef.current[filter.id]?.filterState?.value ===
undefined;
const appliedDataMask = dataMaskApplied[filter.id];
const isFirstTimeInitialization = !initializedFilters.has(filter.id);
// force instant updating on initialization for filters with `requiredFirst` is true or instant filters
if (
// filterState.value === undefined - means that value not initialized
// Auto-apply when filter has value but empty extraFormData in applied state
// This fixes the bug where defaultDataMask.filterState.value exists but extraFormData is empty
// Only auto-apply if: value matches what's applied AND extraFormData is missing in applied but present in incoming
const needsAutoApply =
appliedDataMask?.filterState?.value !== undefined &&
dataMask.filterState?.value !== undefined &&
isFirstTimeInitialization &&
filter.requiredFirst
) {
isEqual(
appliedDataMask.filterState.value,
dataMask.filterState.value,
) &&
(!appliedDataMask?.extraFormData ||
Object.keys(appliedDataMask.extraFormData || {}).length === 0) &&
dataMask.extraFormData &&
Object.keys(dataMask.extraFormData).length > 0;
// Force instant updating for requiredFirst filters or auto-apply when needed
const shouldDispatch =
dataMask.filterState?.value !== undefined &&
((isFirstTimeInitialization && filter.requiredFirst) ||
needsAutoApply);
if (shouldDispatch) {
dispatch(updateDataMask(filter.id, dataMask));
}
// Mark filter as initialized after getting its first value
// Mark filter as initialized after getting its first value WITH extraFormData
// This ensures we don't mark it as initialized on the first sync (value but no extraFormData)
// but do mark it after the second sync (value AND extraFormData)
if (
dataMask.filterState?.value !== undefined &&
dataMask.extraFormData &&
Object.keys(dataMask.extraFormData).length > 0 &&
!initializedFilters.has(filter.id)
) {
setInitializedFilters(prev => new Set(prev).add(filter.id));
@@ -261,7 +278,13 @@ const FilterBar: FC<FiltersBarProps> = ({
};
});
},
[dispatch, setDataMaskSelected, initializedFilters, setInitializedFilters],
[
dispatch,
setDataMaskSelected,
initializedFilters,
setInitializedFilters,
dataMaskApplied,
],
);
useEffect(() => {
@@ -509,7 +509,7 @@ test('deletes a filter including dependencies', async () => {
}),
),
);
});
}, 30000);
test('reorders filters via drag and drop', async () => {
const nativeFilterConfig = [
@@ -48,3 +48,6 @@ export const DEFAULT_CROSS_FILTER_SCOPING: NativeFilterScope = {
rootPath: [DASHBOARD_ROOT_ID],
excluded: [],
};
export const CHART_WIDTH = 4;
export const CHART_HEIGHT = 50;
@@ -297,9 +297,16 @@ describe('ControlPanelsContainer', () => {
(featureFlag: FeatureFlag) => featureFlag === FeatureFlag.Matrixify,
);
// Register control panel for line chart
getChartControlPanelRegistry().registerValue('line', {
controlPanelSections: [],
});
const props = getDefaultProps();
// Use a chart type that supports matrixify (not a table)
props.form_data = {
...props.form_data,
viz_type: 'line',
matrixify_enable_vertical_layout: true,
};
@@ -319,6 +326,7 @@ describe('ControlPanelsContainer', () => {
...props,
form_data: {
...props.form_data,
viz_type: 'line',
matrixify_enable_vertical_layout: true,
matrixify_dimension_columns: {
dimension: 'country',
@@ -336,6 +344,9 @@ describe('ControlPanelsContainer', () => {
});
expect(matrixifyTabAfterSave).toHaveAttribute('aria-selected', 'true');
});
// Clean up
getChartControlPanelRegistry().remove('line');
});
test('should automatically switch to Matrixify tab when matrixify becomes enabled', async () => {
@@ -344,7 +355,17 @@ describe('ControlPanelsContainer', () => {
(featureFlag: FeatureFlag) => featureFlag === FeatureFlag.Matrixify,
);
// Register control panel for line chart
getChartControlPanelRegistry().registerValue('line', {
controlPanelSections: [],
});
const props = getDefaultProps();
// Use a chart type that supports matrixify (not a table)
props.form_data = {
...props.form_data,
viz_type: 'line',
};
const { rerender } = render(<ControlPanelsContainer {...props} />, {
useRedux: true,
@@ -359,6 +380,7 @@ describe('ControlPanelsContainer', () => {
...props,
form_data: {
...props.form_data,
viz_type: 'line',
matrixify_enable_horizontal_layout: true,
},
};
@@ -377,5 +399,92 @@ describe('ControlPanelsContainer', () => {
'aria-selected',
'false',
);
// Clean up
getChartControlPanelRegistry().remove('line');
});
test('should not show Matrixify tab for table chart types', async () => {
// Enable Matrixify feature flag
mockIsFeatureEnabled.mockImplementation(
(featureFlag: FeatureFlag) => featureFlag === FeatureFlag.Matrixify,
);
// All table-type charts that don't support matrixify
const tableVizTypes = [
'table',
'ag-grid-table',
'pivot_table_v2',
'time_table',
'time_pivot',
];
for (const vizType of tableVizTypes) {
const props = getDefaultProps();
props.form_data = {
...props.form_data,
viz_type: vizType,
};
render(<ControlPanelsContainer {...props} />, {
useRedux: true,
});
// Wait for tabs to be rendered
await waitFor(() => {
expect(screen.getByRole('tab', { name: /data/i })).toBeInTheDocument();
});
// Check that Matrixify tab does not exist for table chart types
expect(
screen.queryByRole('tab', { name: /matrixify/i }),
).not.toBeInTheDocument();
}
});
test('should show Matrixify tab for supported chart types', async () => {
// Enable Matrixify feature flag
mockIsFeatureEnabled.mockImplementation(
(featureFlag: FeatureFlag) => featureFlag === FeatureFlag.Matrixify,
);
// Register control panels for non-table chart types
const simpleConfig = { controlPanelSections: [] };
getChartControlPanelRegistry().registerValue('line', simpleConfig);
getChartControlPanelRegistry().registerValue('bar', simpleConfig);
getChartControlPanelRegistry().registerValue('pie', simpleConfig);
// Non-table chart types that support matrixify
const supportedVizTypes = ['line', 'bar', 'pie'];
for (const vizType of supportedVizTypes) {
const props = getDefaultProps();
props.form_data = {
...props.form_data,
viz_type: vizType,
};
const { unmount } = render(<ControlPanelsContainer {...props} />, {
useRedux: true,
});
// Wait for Matrixify tab to be rendered
await waitFor(() => {
expect(
screen.getByRole('tab', { name: /matrixify/i }),
).toBeInTheDocument();
});
// Also verify Data tab exists
expect(screen.getByRole('tab', { name: /data/i })).toBeInTheDocument();
// Clean up this render before the next iteration
unmount();
}
// Clean up registered chart types
getChartControlPanelRegistry().remove('line');
getChartControlPanelRegistry().remove('bar');
getChartControlPanelRegistry().remove('pie');
});
});
@@ -40,6 +40,7 @@ import {
usePrevious,
isFeatureEnabled,
FeatureFlag,
VizType,
} from '@superset-ui/core';
import { styled, css, SupersetTheme, useTheme } from '@apache-superset/core/ui';
import {
@@ -83,6 +84,15 @@ const TABS_KEYS = {
MATRIXIFY: 'MATRIXIFY',
};
// Table charts don't support matrixify feature
const MATRIXIFY_INCOMPATIBLE_CHARTS = new Set([
VizType.Table,
VizType.TableAgGrid,
VizType.PivotTable,
VizType.TimeTable,
VizType.TimePivot,
]);
export type ControlPanelsContainerProps = {
exploreState: ExplorePageState['explore'];
actions: ExploreActions;
@@ -794,7 +804,9 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
]);
const showCustomizeTab = customizeSections.length > 0;
const showMatrixifyTab = isFeatureEnabled(FeatureFlag.Matrixify);
const showMatrixifyTab =
isFeatureEnabled(FeatureFlag.Matrixify) &&
!MATRIXIFY_INCOMPATIBLE_CHARTS.has(form_data.viz_type as VizType);
// Check if matrixify is enabled in form_data
const matrixifyIsEnabled =
@@ -31,6 +31,8 @@ import fetchMock from 'fetch-mock';
import * as saveModalActions from 'src/explore/actions/saveModalActions';
import SaveModal, { PureSaveModal } from 'src/explore/components/SaveModal';
import * as dashboardStateActions from 'src/dashboard/actions/dashboardState';
import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants';
import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
jest.mock('@superset-ui/core/components/Select', () => ({
...jest.requireActual('@superset-ui/core/components/Select/AsyncSelect'),
@@ -42,6 +44,18 @@ jest.mock('@superset-ui/core/components/Select', () => ({
),
}));
jest.mock('@superset-ui/core/components/TreeSelect', () => ({
TreeSelect: ({ onChange, disabled }) => {
return (
<input
data-test="mock-tree-select"
disabled={disabled}
onChange={({ target: { value } }) => onChange(value)}
/>
);
},
}));
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const initialState = {
@@ -429,3 +443,372 @@ test('dispatches removeChartState when saving and going to dashboard', async ()
// Clean up
removeChartStateSpy.mockRestore();
});
test('disables tab selector when no dashboard selected', () => {
const { getByRole, getByTestId } = setup();
fireEvent.click(getByRole('radio', { name: 'Save as...' }));
const tabSelector = getByTestId('mock-tree-select');
expect(tabSelector).toBeInTheDocument();
expect(tabSelector).toBeDisabled();
});
test('renders tab selector when saving as', async () => {
const { getByRole, getByTestId } = setup();
fireEvent.click(getByRole('radio', { name: 'Save as...' }));
const selection = getByTestId('mock-async-select');
fireEvent.change(selection, { target: { value: '1' } });
const tabSelector = getByTestId('mock-tree-select');
expect(tabSelector).toBeInTheDocument();
expect(tabSelector).toBeDisabled();
});
test('onDashboardChange triggers tabs load for existing dashboard', async () => {
const dashboardId = mockEvent.value;
fetchMock.get(`glob:*/api/v1/dashboard/${dashboardId}/tabs`, {
json: {
result: {
tab_tree: [
{ value: 'tab1', title: 'Main Tab' },
{ value: 'tab2', title: 'Tab' },
],
},
},
});
const component = new PureSaveModal(defaultProps);
const loadTabsMock = jest
.fn()
.mockResolvedValue([{ value: 'tab1', title: 'Main Tab' }]);
component.loadTabs = loadTabsMock;
await component.onDashboardChange({
value: dashboardId,
label: 'Test Dashboard',
});
expect(loadTabsMock).toHaveBeenCalledWith(dashboardId);
});
test('onTabChange correctly updates selectedTab via forceUpdate', () => {
const component = new PureSaveModal(defaultProps);
component.state = {
...component.state,
tabsData: [
{
value: 'tab1',
title: 'Main Tab',
key: 'tab1',
children: [
{
value: 'tab2',
title: 'Analytics Tab',
key: 'tab2',
},
],
},
],
};
component.setState = function (stateUpdate) {
if (typeof stateUpdate === 'function') {
this.state = { ...this.state, ...stateUpdate(this.state) };
} else {
this.state = { ...this.state, ...stateUpdate };
}
}.bind(component);
component.onTabChange('tab2');
expect(component.state.selectedTab).toEqual({
value: 'tab2',
label: 'Analytics Tab',
});
});
test('chart placement logic finds row with available space', () => {
// Test case 1: Row has space (8 + 4 = 12 <= 12)
const positionJson1 = {
tab1: {
type: 'TABS',
id: 'tab1',
children: ['row1'],
},
row1: {
type: 'ROW',
id: 'row1',
children: ['CHART-1'],
meta: {},
},
'CHART-1': {
type: 'CHART',
id: 'CHART-1',
meta: { width: 8 },
},
};
// Test case 2: Row is full (12 + 4 = 16 > 12)
const positionJson2 = {
...positionJson1,
'CHART-1': {
...positionJson1['CHART-1'],
meta: { width: 12 },
},
};
// Test case 3: Multiple charts in row
const positionJson3 = {
tab1: {
type: 'TABS',
id: 'tab1',
children: ['row1'],
},
row1: {
type: 'ROW',
id: 'row1',
children: ['CHART-1', 'CHART-2'],
meta: {},
},
'CHART-1': {
type: 'CHART',
id: 'CHART-1',
meta: { width: 6 },
},
'CHART-2': {
type: 'CHART',
id: 'CHART-2',
meta: { width: 4 },
},
};
const findRowWithSpace = (positionJson, tabChildren) => {
for (const childKey of tabChildren) {
const child = positionJson[childKey];
if (child?.type === 'ROW') {
const rowChildren = child.children || [];
const totalWidth = rowChildren.reduce((sum, key) => {
const component = positionJson[key];
return sum + (component?.meta?.width || 0);
}, 0);
if (totalWidth + CHART_WIDTH <= GRID_COLUMN_COUNT) {
return childKey;
}
}
}
return null;
};
// Test case 1: Should find row with space
expect(findRowWithSpace(positionJson1, ['row1'])).toBe('row1');
// Test case 2: Should not find row (full)
expect(findRowWithSpace(positionJson2, ['row1'])).toBeNull();
// Test case 3: Should not find row (6 + 4 = 10, adding 4 = 14 > 12)
expect(findRowWithSpace(positionJson3, ['row1'])).toBeNull();
});
test('addChartToDashboardTab successfully adds chart to existing row with space', async () => {
const dashboardId = 123;
const chartId = 456;
const tabId = 'TABS_ID';
const sliceName = 'Test Chart';
const positionJson = {
[tabId]: {
type: 'TABS',
id: tabId,
children: ['row1'],
},
row1: {
type: 'ROW',
id: 'row1',
children: ['CHART-1'],
meta: {},
},
'CHART-1': {
type: 'CHART',
id: 'CHART-1',
meta: { width: 8, height: 50, chartId: 100 },
},
};
const mockDashboard = {
id: dashboardId,
position_json: JSON.stringify(positionJson),
};
const SupersetClient = require('@superset-ui/core').SupersetClient;
const originalGet = SupersetClient.get;
const originalPut = SupersetClient.put;
SupersetClient.get = jest.fn().mockResolvedValueOnce({
json: { result: mockDashboard },
});
SupersetClient.put = jest.fn().mockResolvedValueOnce({
json: { result: mockDashboard },
});
const component = new PureSaveModal(defaultProps);
const mockNanoid = jest.spyOn(require('nanoid'), 'nanoid');
mockNanoid.mockReturnValue('test-id');
try {
const response = await component.addChartToDashboardTab(
dashboardId,
chartId,
tabId,
sliceName,
);
expect(SupersetClient.get).toHaveBeenCalledWith({
endpoint: `/api/v1/dashboard/${dashboardId}`,
});
expect(SupersetClient.put).toHaveBeenCalledWith({
endpoint: `/api/v1/dashboard/${dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining('position_json'),
});
const putCall = SupersetClient.put.mock.calls[0][0];
const body = JSON.parse(putCall.body);
const updatedPositionJson = JSON.parse(body.position_json);
expect(updatedPositionJson[`CHART-${chartId}`]).toBeDefined();
expect(updatedPositionJson[`CHART-${chartId}`].meta.chartId).toBe(chartId);
expect(updatedPositionJson.row1.children).toContain(`CHART-${chartId}`);
} finally {
SupersetClient.get = originalGet;
SupersetClient.put = originalPut;
mockNanoid.mockRestore();
}
});
test('addChartToDashboardTab creates new row when no existing row has space', async () => {
const dashboardId = 123;
const chartId = 456;
const tabId = 'TABS_ID';
const sliceName = 'Test Chart';
const positionJson = {
[tabId]: {
type: 'TABS',
id: tabId,
children: ['row1'],
},
row1: {
type: 'ROW',
id: 'row1',
children: ['CHART-1'],
parents: ['ROOT_ID', 'GRID_ID', tabId],
meta: {},
},
'CHART-1': {
type: 'CHART',
id: 'CHART-1',
children: [],
parents: ['ROOT_ID', 'GRID_ID', tabId, 'row1'],
meta: {
width: GRID_COLUMN_COUNT,
height: 50,
chartId: 100,
sliceName: 'Existing Chart',
},
},
};
const mockDashboard = {
id: dashboardId,
position_json: JSON.stringify(positionJson),
};
const SupersetClient = require('@superset-ui/core').SupersetClient;
const originalGet = SupersetClient.get;
const originalPut = SupersetClient.put;
SupersetClient.get = jest.fn().mockResolvedValueOnce({
json: { result: mockDashboard },
});
let putRequestBody = null;
SupersetClient.put = jest.fn().mockImplementationOnce(request => {
putRequestBody = request;
return Promise.resolve({
json: { result: mockDashboard },
});
});
const component = new PureSaveModal(defaultProps);
const mockRowId = 'test-row-id';
const mockNanoid = jest.spyOn(require('nanoid'), 'nanoid');
mockNanoid.mockReturnValueOnce(mockRowId);
try {
await component.addChartToDashboardTab(
dashboardId,
chartId,
tabId,
sliceName,
);
expect(SupersetClient.put).toHaveBeenCalled();
const body = JSON.parse(putRequestBody.body);
const updatedPositionJson = JSON.parse(body.position_json);
expect(updatedPositionJson[`ROW-${mockRowId}`]).toBeDefined();
expect(updatedPositionJson[`ROW-${mockRowId}`].type).toBe('ROW');
expect(updatedPositionJson[tabId].children).toContain(`ROW-${mockRowId}`);
expect(updatedPositionJson[`CHART-${chartId}`]).toBeDefined();
expect(updatedPositionJson[`ROW-${mockRowId}`].children).toContain(
`CHART-${chartId}`,
);
} finally {
SupersetClient.get = originalGet;
SupersetClient.put = originalPut;
mockNanoid.mockRestore();
}
});
test('addChartToDashboardTab handles empty position_json', async () => {
const dashboardId = 123;
const chartId = 456;
const tabId = 'TABS_ID';
const sliceName = 'Test Chart';
const mockDashboard = {
id: dashboardId,
position_json: null,
};
const SupersetClient = require('@superset-ui/core').SupersetClient;
const originalGet = SupersetClient.get;
const originalPut = SupersetClient.put;
SupersetClient.get = jest.fn().mockResolvedValueOnce({
json: { result: mockDashboard },
});
SupersetClient.put = jest.fn().mockResolvedValueOnce({
json: { result: mockDashboard },
});
const component = new PureSaveModal(defaultProps);
const mockNanoid = jest.spyOn(require('nanoid'), 'nanoid');
mockNanoid.mockReturnValue('test-id');
try {
await expect(
component.addChartToDashboardTab(dashboardId, chartId, tabId, sliceName),
).rejects.toThrow(`Tab ${tabId} not found in positionJson`);
} finally {
SupersetClient.get = originalGet;
SupersetClient.put = originalPut;
mockNanoid.mockRestore();
}
});
@@ -19,6 +19,7 @@
/* eslint camelcase: 0 */
import { ChangeEvent, FormEvent, Component } from 'react';
import { Dispatch } from 'redux';
import { nanoid } from 'nanoid';
import rison from 'rison';
import { connect } from 'react-redux';
import { withRouter, RouteComponentProps } from 'react-router-dom';
@@ -32,20 +33,24 @@ import {
Input,
Loading,
Divider,
TreeSelect,
} from '@superset-ui/core/components';
import { t, logging } from '@apache-superset/core';
import { DatasourceType, isDefined, SupersetClient } from '@superset-ui/core';
import { css, styled, Alert } from '@apache-superset/core/ui';
import { Radio } from '@superset-ui/core/components/Radio';
import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils';
import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions';
import { SaveActionType } from 'src/explore/types';
import { SaveActionType, ChartStatusType } from 'src/explore/types';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import {
removeChartState,
updateChartState,
} from 'src/dashboard/actions/dashboardState';
import { Dashboard } from 'src/types/Dashboard';
import { TabNode, TabTreeNode } from '../types';
import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants';
// Session storage key for recent dashboard
const SK_DASHBOARD_ID = 'save_chart_recent_dashboard';
@@ -71,6 +76,8 @@ type SaveModalState = {
isLoading: boolean;
saveStatus?: string | null;
dashboard?: { label: string; value: string | number };
selectedTab?: { label: string; value: string | number };
tabsData: TabTreeNode[];
};
export const StyledModal = styled(Modal)`
@@ -90,9 +97,13 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
this.state = {
newSliceName: props.sliceName,
datasetName: props.datasource?.name,
action: this.canOverwriteSlice() ? 'overwrite' : 'saveas',
action: this.canOverwriteSlice()
? ChartStatusType.overwrite
: ChartStatusType.saveas,
isLoading: false,
dashboard: undefined,
tabsData: [],
selectedTab: undefined,
};
this.onDashboardChange = this.onDashboardChange.bind(this);
this.onSliceNameChange = this.onSliceNameChange.bind(this);
@@ -132,6 +143,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
this.setState({
dashboard: { label: result.dashboard_title, value: result.id },
});
await this.loadTabs(dashboardId);
}
} catch (error) {
logging.warn(error);
@@ -151,10 +163,20 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
this.setState({ newSliceName: event.target.value });
}
onDashboardChange(dashboard: { label: string; value: string | number }) {
this.setState({ dashboard });
}
onDashboardChange = async (dashboard: {
label: string;
value: string | number;
}) => {
this.setState({
dashboard,
tabsData: [],
selectedTab: undefined,
});
if (typeof dashboard.value === 'number') {
await this.loadTabs(dashboard.value);
}
};
changeAction(action: SaveActionType) {
this.setState({ action });
}
@@ -210,6 +232,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
delete formData.url_params;
let dashboard: DashboardGetResponse | null = null;
let selectedTabId: string | undefined;
if (this.state.dashboard) {
let validId = this.state.dashboard.value;
if (this.isNewDashboard()) {
@@ -231,6 +254,12 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
? sliceDashboards
: [...sliceDashboards, dashboard.id];
formData.dashboards = sliceDashboards;
if (
this.state.action === ChartStatusType.saveas &&
this.state.selectedTab?.value !== 'OUT_OF_TAB'
) {
selectedTabId = this.state.selectedTab?.value as string;
}
}
}
@@ -262,6 +291,21 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
}
: null,
);
if (dashboard && selectedTabId) {
try {
await this.addChartToDashboardTab(
dashboard.id,
value.id,
selectedTabId,
this.state.newSliceName,
);
} catch (error) {
logging.error('Error adding chart to dashboard tab:', error);
this.props.addDangerToast(
t('Chart was saved but could not be added to the selected tab.'),
);
}
}
}
try {
@@ -276,11 +320,14 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
// Go to new dashboard url
if (gotodash && dashboard) {
let url = dashboard.url;
if (this.state.selectedTab?.value) {
url += `#${this.state.selectedTab.value}`;
}
this.props.dispatch(removeChartState(value.id));
this.props.history.push(dashboard.url);
this.props.history.push(url);
return;
}
const searchParams = this.handleRedirect(window.location.search, value);
this.props.history.replace(`/explore/?${searchParams.toString()}`);
@@ -291,6 +338,114 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
}
}
/* Adds a chart to the specified dashboard tab. If an existing row has space, the chart is added there; otherwise, a new row is created.
* @param {number} dashboardId - ID of the dashboard.
* @param {number} chartId - ID of the chart to add.
* @param {string} tabId - ID of the dashboard tab where the chart is added.
* @param {string | undefined} sliceName - Chart name
*/
addChartToDashboardTab = async (
dashboardId: number,
chartId: number,
tabId: string,
sliceName: string | undefined,
) => {
try {
const dashboardResponse = await SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}`,
});
const dashboard = dashboardResponse.json.result;
let positionJson = dashboard.position_json;
if (typeof positionJson === 'string') {
positionJson = JSON.parse(positionJson);
}
positionJson = positionJson || {};
const chartKey = `CHART-${chartId}`;
// Find a row in the tab with available space
const tabChildren = positionJson[tabId]?.children || [];
let targetRowKey: string | null = null;
for (const childKey of tabChildren) {
const child = positionJson[childKey];
if (child?.type === 'ROW') {
const rowChildren = child.children || [];
const totalWidth = rowChildren.reduce((sum: number, key: string) => {
const component = positionJson[key];
return sum + (component?.meta?.width || 0);
}, 0);
if (totalWidth + CHART_WIDTH <= GRID_COLUMN_COUNT) {
targetRowKey = childKey;
break;
}
}
}
const updatedPositionJson = { ...positionJson };
// Create a new row if no existing row has space
if (!targetRowKey) {
targetRowKey = `ROW-${nanoid()}`;
updatedPositionJson[targetRowKey] = {
type: 'ROW',
id: targetRowKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', tabId],
meta: {
background: 'BACKGROUND_TRANSPARENT',
},
};
if (positionJson[tabId]) {
updatedPositionJson[tabId] = {
...positionJson[tabId],
children: [...(positionJson[tabId].children || []), targetRowKey],
};
} else {
throw new Error(`Tab ${tabId} not found in positionJson`);
}
}
updatedPositionJson[chartKey] = {
type: 'CHART',
id: chartKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', tabId, targetRowKey],
meta: {
width: CHART_WIDTH,
height: CHART_HEIGHT,
chartId,
sliceName: sliceName ?? `Chart ${chartId}`,
},
};
// Add chart to the target row
updatedPositionJson[targetRowKey] = {
...updatedPositionJson[targetRowKey],
children: [
...(updatedPositionJson[targetRowKey].children || []),
chartKey,
],
};
const response = await SupersetClient.put({
endpoint: `/api/v1/dashboard/${dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
position_json: JSON.stringify(updatedPositionJson),
}),
});
return response;
} catch (error) {
throw new Error(`Error adding chart to dashboard tab: ${error}`);
}
};
loadDashboard = async (id: number) => {
const response = await SupersetClient.get({
endpoint: `/api/v1/dashboard/${id}`,
@@ -332,6 +487,101 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
totalCount: count,
};
};
// Loads dashboard tabs and returns the tab hierarchy for display.
loadTabs = async (dashboardId: number) => {
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}/tabs`,
});
const { result } = response.json;
if (!result || !Array.isArray(result.tab_tree)) {
logging.warn('Invalid tabs response format');
this.setState({ tabsData: [] });
return [];
}
const tabTree = result.tab_tree;
const gridTabIds = new Set<string>();
const convertToTreeData = (nodes: TabNode[]): TabTreeNode[] =>
nodes.map(node => {
const isGridTab =
Array.isArray(node.parents) && node.parents.includes('GRID_ID');
if (isGridTab) {
gridTabIds.add(node.value);
}
return {
value: node.value,
title: node.title,
key: node.value,
children:
node.children && node.children.length > 0
? convertToTreeData(node.children)
: undefined,
};
});
const treeData = convertToTreeData(tabTree);
// Add "Out of tab" option at the beginning
if (gridTabIds.size > 0) {
const tabsDataWithOutOfTab = [
{
value: 'OUT_OF_TAB',
title: 'Out of tab',
key: 'OUT_OF_TAB',
children: undefined,
},
...treeData,
];
this.setState({
tabsData: tabsDataWithOutOfTab,
selectedTab: { value: 'OUT_OF_TAB', label: 'Out of tab' },
});
} else {
const firstTab = treeData[0];
this.setState({
tabsData: treeData,
selectedTab: { value: firstTab.value, label: firstTab.title },
});
}
return treeData;
} catch (error) {
logging.error('Error loading tabs:', error);
this.setState({ tabsData: [] });
return [];
}
};
onTabChange = (value: string) => {
if (value) {
const findTabInTree = (data: TabTreeNode[]): TabTreeNode | null => {
for (const item of data) {
if (item.value === value) {
return item;
}
if (item.children) {
const found = findTabInTree(item.children);
if (found) return found;
}
}
return null;
};
const selectedTab = findTabInTree(this.state.tabsData);
if (selectedTab) {
this.setState({
selectedTab: {
value: selectedTab.value,
label: selectedTab.title,
},
});
}
} else {
this.setState({ selectedTab: undefined });
}
};
renderSaveChartModal = () => {
const info = this.info();
@@ -350,7 +600,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
<Radio
id="saveas-radio"
data-test="saveas-radio"
checked={this.state.action === 'saveas'}
checked={this.state.action === ChartStatusType.saveas}
onChange={() => this.changeAction('saveas')}
>
{t('Save as...')}
@@ -404,6 +654,27 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
}
/>
</FormItem>
{this.state.action === ChartStatusType.saveas && (
<FormItem
label={t('Add to tabs')}
data-test="save-chart-modal-select-tabs-form"
>
<TreeSelect
showSearch
allowClear
treeDefaultExpandAll
treeData={this.state.tabsData}
onChange={this.onTabChange}
value={this.state.selectedTab?.value}
disabled={
!this.state.dashboard ||
typeof this.state.dashboard.value === 'string' ||
this.state.tabsData.length === 0
}
placeholder={t('Select a tab')}
/>
</FormItem>
)}
{info && <Alert type="info" message={info} closable={false} />}
{this.props.alert && (
<Alert
@@ -82,6 +82,7 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
});
}
const theme = useTheme();
const columnConfigs = useMemo(() => {
const configs: Record<string, ColumnConfigInfo> = {};
colnames?.forEach((col, idx) => {
@@ -100,6 +101,7 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
const [showAllColumns, setShowAllColumns] = useState(false);
const getColumnInfo = (col: string) => columnConfigs[col] || {};
const setColumnConfig = (col: string, config: T) => {
if (onChange) {
// Only keep configs for known columns
@@ -168,7 +168,7 @@ const currencyFormat: ControlFormItemSpec<'CurrencyControl'> = {
controlType: 'CurrencyControl',
label: t('Currency format'),
description: t(
'Customize chart metrics or columns with currency symbols as prefixes or suffixes. Choose a symbol from dropdown or type your own.',
"Format metrics or columns with currency symbols as prefixes or suffixes. Choose a symbol manually or use 'Auto-detect' to apply the correct symbol based on the dataset's currency code column. When multiple currencies are present, formatting falls back to neutral numbers.",
),
debounceDelay: 200,
};
@@ -0,0 +1,38 @@
/**
* 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 } from 'spec/helpers/testing-library';
import { CurrencyControl } from './CurrencyControl';
test('CurrencyControl renders position and symbol selects', () => {
const { container } = render(
<CurrencyControl onChange={jest.fn()} value={{}} />,
{
useRedux: true,
initialState: {
common: { currencies: ['USD', 'EUR'] },
explore: { datasource: {} },
},
},
);
expect(
container.querySelector('[data-test="currency-control-container"]'),
).toBeInTheDocument();
expect(container.querySelectorAll('.ant-select')).toHaveLength(2);
});
@@ -16,14 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@apache-superset/core';
import { Currency, ensureIsArray, getCurrencySymbol } from '@superset-ui/core';
import { css, styled } from '@apache-superset/core/ui';
import { css, styled, useTheme } from '@apache-superset/core/ui';
import { CSSObject } from '@emotion/react';
import { Select, type SelectProps } from '@superset-ui/core/components';
import { ViewState } from 'src/views/types';
import { ExplorePageState } from 'src/explore/types';
import ControlHeader from '../../ControlHeader';
export interface CurrencyControlProps {
@@ -67,19 +68,74 @@ export const CurrencyControl = ({
currencySelectAdditionalStyles,
...props
}: CurrencyControlProps) => {
const theme = useTheme();
const currencies = useSelector<ViewState, string[]>(
state => state.common?.currencies,
);
const currenciesOptions = useMemo(
() =>
ensureIsArray(currencies).map(currencyCode => ({
value: currencyCode,
label: `${getCurrencySymbol({
symbol: currencyCode,
})} (${currencyCode})`,
})),
[currencies],
const currencyCodeColumn = useSelector<ExplorePageState, string | undefined>(
state => state?.explore?.datasource?.currency_code_column,
);
const currenciesOptions = useMemo(() => {
const options = ensureIsArray(currencies).map(currencyCode => ({
value: currencyCode,
label: `${getCurrencySymbol({
symbol: currencyCode,
})} (${currencyCode})`,
}));
const autoDetectOption = currencyCodeColumn
? [
{
value: 'AUTO',
label: t('Auto-detect'),
className: 'currency-auto-detect-option',
},
]
: [];
return [
...autoDetectOption,
...options,
{ value: '', label: t('Custom...') },
];
}, [currencies, currencyCodeColumn]);
const currencySortComparator = useCallback(
(
a: { value?: string | number },
b: { value?: string | number },
): number => {
if (a.value === 'AUTO') return -1;
if (b.value === 'AUTO') return 1;
if (a.value === '') return 1;
if (b.value === '') return -1;
const labelA = String(a.value ?? '');
const labelB = String(b.value ?? '');
return labelA.localeCompare(labelB);
},
[],
);
const renderCurrencyPopup = useMemo(
() =>
currencyCodeColumn
? (menu: React.ReactNode) => (
<div
css={css`
.currency-auto-detect-option {
border-bottom: 1px solid ${theme.colorBorderSecondary};
margin-bottom: ${theme.sizeUnit}px;
}
`}
>
{menu}
</div>
)
: undefined,
[currencyCodeColumn, theme],
);
return (
<>
<ControlHeader {...props} />
@@ -92,7 +148,7 @@ export const CurrencyControl = ({
${currencySelectAdditionalStyles};
}
`}
className="currency-control-container"
data-test="currency-control-container"
>
<Select
ariaLabel={t('Currency prefix or suffix')}
@@ -117,6 +173,8 @@ export const CurrencyControl = ({
value={currency?.symbol}
allowClear
allowNewOptions
sortComparator={currencySortComparator}
popupRender={renderCurrencyPopup}
{...currencySelectOverrideProps}
/>
</CurrencyControlContainer>
+19
View File
@@ -35,6 +35,11 @@ import { Slice } from 'src/types/Chart';
export type SaveActionType = 'overwrite' | 'saveas';
export enum ChartStatusType {
overwrite = 'overwrite',
saveas = 'saveas',
}
export type ChartStatus =
| 'loading'
| 'rendered'
@@ -123,3 +128,17 @@ export interface ExplorePageState {
};
sliceEntities?: JsonObject; // propagated from Dashboard view
}
export interface TabNode {
value: string;
title: string;
parents: string[];
children?: TabNode[];
}
export interface TabTreeNode {
value: string;
title: string;
key: string;
children?: TabTreeNode[];
}
@@ -65,6 +65,7 @@ export type DatasetObject = {
catalog?: string;
description: string | null;
main_dttm_col: string;
currency_code_column?: string;
offset?: number;
default_endpoint?: string;
cache_timeout?: number;
+21 -21
View File
@@ -11,7 +11,7 @@
"dependencies": {
"cookie": "^1.1.1",
"hot-shots": "^12.1.0",
"ioredis": "^5.9.0",
"ioredis": "^5.9.2",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.17.21",
"uuid": "^11.1.0",
@@ -25,7 +25,7 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.23",
"@types/node": "^25.0.8",
"@types/node": "^25.0.9",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
@@ -35,7 +35,7 @@
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.0.0",
"jest": "^29.7.0",
"prettier": "^3.7.4",
"prettier": "^3.8.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
@@ -1823,9 +1823,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
"version": "25.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3546,9 +3546,9 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ioredis": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.0.tgz",
"integrity": "sha512-T3VieIilNumOJCXI9SDgo4NnF6sZkd6XcmPi6qWtw4xqbt8nNz/ZVNiIH1L9puMTSHZh1mUWA4xKa2nWPF4NwQ==",
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.0",
@@ -5512,9 +5512,9 @@
}
},
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true,
"license": "MIT",
"bin": {
@@ -7943,9 +7943,9 @@
"dev": true
},
"@types/node": {
"version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
"version": "25.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
"dev": true,
"requires": {
"undici-types": "~7.16.0"
@@ -9133,9 +9133,9 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ioredis": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.0.tgz",
"integrity": "sha512-T3VieIilNumOJCXI9SDgo4NnF6sZkd6XcmPi6qWtw4xqbt8nNz/ZVNiIH1L9puMTSHZh1mUWA4xKa2nWPF4NwQ==",
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
"requires": {
"@ioredis/commands": "1.5.0",
"cluster-key-slot": "^1.1.0",
@@ -10666,9 +10666,9 @@
"dev": true
},
"prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true
},
"pretty-format": {
+3 -3
View File
@@ -19,7 +19,7 @@
"dependencies": {
"cookie": "^1.1.1",
"hot-shots": "^12.1.0",
"ioredis": "^5.9.0",
"ioredis": "^5.9.2",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.17.21",
"uuid": "^11.1.0",
@@ -33,7 +33,7 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.23",
"@types/node": "^25.0.8",
"@types/node": "^25.0.9",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
@@ -43,7 +43,7 @@
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.0.0",
"jest": "^29.7.0",
"prettier": "^3.7.4",
"prettier": "^3.8.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
+9
View File
@@ -1533,6 +1533,15 @@ class ChartDataResponseResult(Schema):
rejected_filters = fields.List(
fields.Dict(), metadata={"description": "A list with rejected filters"}
)
detected_currency = fields.String(
metadata={
"description": "Detected ISO 4217 currency code when AUTO mode is used. "
"Returns the currency code if all filtered data contains a single currency "
"or null if multiple currencies are present."
},
allow_none=True,
load_default=None,
)
from_dttm = fields.Integer(
metadata={"description": "Start timestamp of time range"},
required=False,
+3
View File
@@ -52,6 +52,9 @@ def load_examples_run(
logger.info("Loading [Birth names]")
examples.load_birth_names(only_metadata, force)
logger.info("Loading [International Sales]")
examples.load_international_sales(only_metadata, force)
if load_test_data:
logger.info("Loading [Tabbed dashboard]")
examples.load_tabbed_dashboard(only_metadata)
+65
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import copy
import logging
from typing import Any, Callable, TYPE_CHECKING
from flask_babel import _
@@ -32,11 +33,18 @@ from superset.utils.core import (
get_column_name,
get_time_filter_status,
)
from superset.utils.currency import (
detect_currency,
detect_currency_from_df,
has_auto_currency_in_column_config,
)
if TYPE_CHECKING:
from superset.common.query_context import QueryContext
from superset.common.query_object import QueryObject
logger = logging.getLogger(__name__)
def _get_datasource(query_context: QueryContext, query_obj: QueryObject) -> Explorable:
return query_obj.datasource or query_context.datasource
@@ -89,6 +97,59 @@ def _get_query(
return result
def _detect_currency(
query_context: QueryContext,
query_obj: QueryObject,
datasource: Explorable,
df: Any = None,
) -> str | None:
"""
Detect currency from filtered data for AUTO mode currency formatting.
First attempts to detect from the provided dataframe if the currency
column is present. Falls back to a separate query only when needed.
Checks both top-level currency_format (used by Pie, Timeseries, etc.)
and column_config (used by Table charts) for AUTO currency settings.
:param query_context: The query context with form_data containing currency_format
:param query_obj: The original query object with filters
:param datasource: The datasource being queried
:param df: Optional dataframe to detect currency from (avoids extra query)
:return: ISO 4217 currency code (e.g., "USD") or None
"""
form_data = query_context.form_data or {}
# Check top-level currency_format (for Pie, Timeseries, etc.)
currency_format = form_data.get("currency_format", {})
top_level_auto = (
isinstance(currency_format, dict) and currency_format.get("symbol") == "AUTO"
)
# Check column_config (for Table charts)
column_config_auto = has_auto_currency_in_column_config(form_data)
# Only detect if AUTO is configured somewhere
if not top_level_auto and not column_config_auto:
return None
currency_column = getattr(datasource, "currency_code_column", None)
if not currency_column:
return None
if df is not None and currency_column in df.columns:
return detect_currency_from_df(df, currency_column)
return detect_currency(
datasource=datasource,
filters=query_obj.filter,
granularity=query_obj.granularity,
from_dttm=query_obj.from_dttm,
to_dttm=query_obj.to_dttm,
extras=query_obj.extras,
)
def _get_full(
query_context: QueryContext,
query_obj: QueryObject,
@@ -105,6 +166,9 @@ def _get_full(
payload["coltypes"] = extract_dataframe_dtypes(df, datasource)
payload["data"] = query_context.get_data(df, payload["coltypes"])
payload["result_format"] = query_context.result_format
payload["detected_currency"] = _detect_currency(
query_context, query_obj, datasource, df
)
del payload["df"]
applied_time_columns, rejected_time_columns = get_time_filter_status(
@@ -133,6 +197,7 @@ def _get_full(
"coltypes": payload.get("coltypes"),
"rowcount": payload.get("rowcount"),
"sql_rowcount": payload.get("sql_rowcount"),
"detected_currency": payload.get("detected_currency"),
}
return payload
+34
View File
@@ -120,6 +120,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
self._apply_granularity(query_object, form_data, datasource)
self._apply_filters(query_object)
self._add_tooltip_columns(query_object, form_data)
self._add_currency_column(query_object, form_data, datasource)
return query_object
def _add_tooltip_columns(
@@ -195,6 +196,39 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
tooltip_columns.append(column_name)
return tooltip_columns
def _add_currency_column(
self,
query_object: QueryObject,
form_data: dict[str, Any] | None,
datasource: Explorable,
) -> None:
"""
Add currency_code_column to the query for pivot_table_v2 cell-level formatting.
When currency_format.symbol is 'AUTO', injects the datasource's
currency_code_column into query columns for per-cell currency formatting.
"""
if not form_data or not query_object.columns:
return
if form_data.get("viz_type") != "pivot_table_v2":
return
currency_format = form_data.get("currency_format", {})
if not (
isinstance(currency_format, dict)
and currency_format.get("symbol") == "AUTO"
):
return
currency_column = getattr(datasource, "currency_code_column", None)
if not currency_column:
return
existing_columns = self._get_existing_column_names(query_object.columns)
if currency_column not in existing_columns:
query_object.columns.append(currency_column)
def _apply_granularity( # noqa: C901
self,
query_object: QueryObject,
+5 -1
View File
@@ -1221,6 +1221,7 @@ class SqlaTable(
table_name = Column(String(250), nullable=False)
main_dttm_col = Column(String(250))
currency_code_column = Column(String(250))
database_id = Column(Integer, ForeignKey("dbs.id"), nullable=False)
fetch_values_predicate = Column(Text)
owners = relationship(owner_class, secondary=sqlatable_user, backref="tables")
@@ -1244,6 +1245,7 @@ class SqlaTable(
export_fields = [
"table_name",
"main_dttm_col",
"currency_code_column",
"description",
"default_endpoint",
"database_id",
@@ -1331,7 +1333,8 @@ class SqlaTable(
@property
def link(self) -> Markup:
name = escape(self.name)
anchor = f'<a target="_blank" href="{self.explore_url}">{name}</a>'
url = escape(self.explore_url)
anchor = f'<a target="_blank" href="{url}">{name}</a>'
return Markup(anchor)
def get_catalog_perm(self) -> str | None:
@@ -1448,6 +1451,7 @@ class SqlaTable(
data_["granularity_sqla"] = self.granularity_sqla
data_["time_grain_sqla"] = self.time_grain_sqla
data_["main_dttm_col"] = self.main_dttm_col
data_["currency_code_column"] = self.currency_code_column
data_["fetch_values_predicate"] = self.fetch_values_predicate
data_["template_params"] = self.template_params
data_["is_sqllab_view"] = self.is_sqllab_view
+1
View File
@@ -295,6 +295,7 @@ class DashboardDatasetSchema(Schema):
sql = fields.Str()
select_star = fields.Str()
main_dttm_col = fields.Str()
currency_code_column = fields.Str()
health_check_message = fields.Str()
fetch_values_predicate = fields.Str()
template_params = fields.Str()
+2
View File
@@ -167,6 +167,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"schema",
"description",
"main_dttm_col",
"currency_code_column",
"normalize_columns",
"always_filter_main_dttm",
"offset",
@@ -250,6 +251,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"schema",
"description",
"main_dttm_col",
"currency_code_column",
"normalize_columns",
"always_filter_main_dttm",
"offset",
+2
View File
@@ -166,6 +166,7 @@ class DatasetPutSchema(Schema):
schema = fields.String(allow_none=True, validate=Length(0, 255))
description = fields.String(allow_none=True)
main_dttm_col = fields.String(allow_none=True)
currency_code_column = fields.String(allow_none=True, validate=Length(0, 250))
normalize_columns = fields.Boolean(allow_none=True, dump_default=False)
always_filter_main_dttm = fields.Boolean(load_default=False)
offset = fields.Integer(allow_none=True)
@@ -313,6 +314,7 @@ class ImportV1DatasetSchema(Schema):
table_name = fields.String(required=True)
main_dttm_col = fields.String(allow_none=True)
currency_code_column = fields.String(allow_none=True)
description = fields.String(allow_none=True)
default_endpoint = fields.String(allow_none=True)
offset = fields.Integer()
+2
View File
@@ -22,6 +22,7 @@ from .css_templates import load_css_templates
from .deck import load_deck_dash
from .energy import load_energy
from .flights import load_flights
from .international_sales import load_international_sales
from .long_lat import load_long_lat_data
from .misc_dashboard import load_misc_dashboard
from .multiformat_time_series import load_multiformat_time_series
@@ -39,6 +40,7 @@ __all__ = [
"load_birth_names",
"load_country_map_data",
"load_css_templates",
"load_international_sales",
"load_deck_dash",
"load_energy",
"load_flights",
+238
View File
@@ -0,0 +1,238 @@
# 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.
"""International sales dataset demonstrating multi-currency transactions."""
import logging
import pandas as pd
from sqlalchemy import Date, inspect, Integer, Numeric, String
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.sql.parse import Table
from ..utils.database import get_example_database # noqa: TID252
from .helpers import get_table_connector_registry
logger = logging.getLogger(__name__)
def get_international_sales_data() -> pd.DataFrame:
"""Generate the international sales dataset with multiple currencies."""
# fmt: off
data = [
# North America - USA (USD)
(1, "2024-01-15", "North America", "USA", "Electronics", "Laptop Pro",
50, 1299.99, 64999.50, 45000.00, 19999.50, "USD", "$"),
(2, "2024-01-15", "North America", "USA", "Electronics", "Smartphone X",
200, 899.99, 179998.00, 120000.00, 59998.00, "USD", "$"),
(3, "2024-01-15", "North America", "USA", "Software", "Office Suite",
500, 149.99, 74995.00, 15000.00, 59995.00, "USD", "$"),
(4, "2024-02-15", "North America", "USA", "Electronics", "Laptop Pro",
75, 1299.99, 97499.25, 67500.00, 29999.25, "USD", "$"),
(5, "2024-02-15", "North America", "USA", "Electronics", "Smartphone X",
250, 899.99, 224997.50, 150000.00, 74997.50, "USD", "$"),
(6, "2024-02-15", "North America", "USA", "Software", "Office Suite",
600, 149.99, 89994.00, 18000.00, 71994.00, "USD", "$"),
(7, "2024-03-15", "North America", "USA", "Electronics", "Laptop Pro",
100, 1299.99, 129999.00, 90000.00, 39999.00, "USD", "$"),
(8, "2024-03-15", "North America", "USA", "Electronics", "Smartphone X",
300, 899.99, 269997.00, 180000.00, 89997.00, "USD", "$"),
# Case normalization test - lowercase 'usd'
(9, "2024-03-15", "North America", "USA", "Software", "Office Suite",
700, 149.99, 104993.00, 21000.00, 83993.00, "usd", "$"),
# North America - Canada (CAD)
(10, "2024-01-15", "North America", "Canada", "Electronics", "Laptop Pro",
30, 1599.99, 47999.70, 35000.00, 12999.70, "CAD", "CA$"),
(11, "2024-01-15", "North America", "Canada", "Electronics", "Smartphone X",
100, 1099.99, 109999.00, 75000.00, 34999.00, "CAD", "CA$"),
(12, "2024-02-15", "North America", "Canada", "Electronics", "Laptop Pro",
40, 1599.99, 63999.60, 46000.00, 17999.60, "CAD", "CA$"),
(13, "2024-02-15", "North America", "Canada", "Software", "Office Suite",
200, 199.99, 39998.00, 8000.00, 31998.00, "CAD", "CA$"),
# Case normalization test - mixed case 'Cad'
(14, "2024-03-15", "North America", "Canada", "Electronics", "Laptop Pro",
50, 1599.99, 79999.50, 57500.00, 22499.50, "Cad", "CA$"),
# Europe - Germany/France (EUR)
(15, "2024-01-15", "Europe", "Germany", "Electronics", "Laptop Pro",
40, 1199.99, 47999.60, 32000.00, 15999.60, "EUR", ""),
(16, "2024-01-15", "Europe", "Germany", "Electronics", "Smartphone X",
150, 849.99, 127498.50, 85000.00, 42498.50, "EUR", ""),
(17, "2024-01-15", "Europe", "France", "Software", "Office Suite",
300, 139.99, 41997.00, 9000.00, 32997.00, "EUR", ""),
(18, "2024-02-15", "Europe", "Germany", "Electronics", "Laptop Pro",
55, 1199.99, 65999.45, 44000.00, 21999.45, "EUR", ""),
(19, "2024-02-15", "Europe", "France", "Electronics", "Smartphone X",
180, 849.99, 152998.20, 102000.00, 50998.20, "EUR", ""),
# Case normalization test - lowercase 'eur'
(20, "2024-02-15", "Europe", "Germany", "Software", "Office Suite",
350, 139.99, 48996.50, 10500.00, 38496.50, "eur", ""),
# Europe - UK (GBP)
(21, "2024-01-15", "Europe", "UK", "Electronics", "Laptop Pro",
35, 999.99, 34999.65, 24500.00, 10499.65, "GBP", "£"),
(22, "2024-01-15", "Europe", "UK", "Electronics", "Smartphone X",
120, 749.99, 89998.80, 66000.00, 23998.80, "GBP", "£"),
(23, "2024-02-15", "Europe", "UK", "Electronics", "Laptop Pro",
45, 999.99, 44999.55, 31500.00, 13499.55, "GBP", "£"),
(24, "2024-02-15", "Europe", "UK", "Software", "Office Suite",
250, 119.99, 29997.50, 7500.00, 22497.50, "GBP", "£"),
# Case normalization test - mixed case 'Gbp'
(25, "2024-03-15", "Europe", "UK", "Electronics", "Laptop Pro",
60, 999.99, 59999.40, 42000.00, 17999.40, "Gbp", "£"),
# Asia - Japan (JPY)
(26, "2024-01-15", "Asia", "Japan", "Electronics", "Laptop Pro",
25, 149999.00, 3749975.00, 2625000.00, 1124975.00, "JPY", "¥"),
(27, "2024-01-15", "Asia", "Japan", "Electronics", "Smartphone X",
80, 99999.00, 7999920.00, 5600000.00, 2399920.00, "JPY", "¥"),
(28, "2024-02-15", "Asia", "Japan", "Electronics", "Laptop Pro",
30, 149999.00, 4499970.00, 3150000.00, 1349970.00, "JPY", "¥"),
(29, "2024-03-15", "Asia", "Japan", "Software", "Office Suite",
150, 14999.00, 2249850.00, 450000.00, 1799850.00, "JPY", "¥"),
# Asia Pacific - Australia (AUD)
(30, "2024-01-15", "Asia Pacific", "Australia", "Electronics", "Laptop Pro",
20, 1899.99, 37999.80, 26000.00, 11999.80, "AUD", "A$"),
(31, "2024-02-15", "Asia Pacific", "Australia", "Electronics", "Smartphone X",
60, 1299.99, 77999.40, 48000.00, 29999.40, "AUD", "A$"),
(32, "2024-03-15", "Asia Pacific", "Australia", "Software", "Office Suite",
100, 219.99, 21999.00, 6000.00, 15999.00, "AUD", "A$"),
# NULL currency tests - Other region
(33, "2024-01-15", "Other", "Unknown", "Electronics", "Generic Device",
10, 500.00, 5000.00, 3500.00, 1500.00, None, None),
(34, "2024-02-15", "Other", "Unknown", "Software", "Basic App",
50, 50.00, 2500.00, 1000.00, 1500.00, None, None),
# Empty string currency test
(35, "2024-03-15", "Other", "Unknown", "Electronics", "Unknown Product",
5, 100.00, 500.00, 350.00, 150.00, "", ""),
# Additional rows for aggregation tests
(36, "2024-01-15", "North America", "USA", "Electronics", "Tablet Pro",
80, 599.99, 47999.20, 32000.00, 15999.20, "USD", "$"),
(37, "2024-02-15", "Europe", "Germany", "Electronics", "Tablet Pro",
65, 549.99, 35749.35, 22750.00, 12999.35, "EUR", ""),
(38, "2024-03-15", "Asia", "Japan", "Electronics", "Tablet Pro",
45, 64999.00, 2924955.00, 1575000.00, 1349955.00, "JPY", "¥"),
# Euro word/symbol normalization tests
(39, "2024-01-15", "Europe", "Spain", "Software", "Cloud Service",
100, 99.99, 9999.00, 5000.00, 4999.00, "euro", ""),
(40, "2024-02-15", "Europe", "Italy", "Software", "Cloud Service",
120, 99.99, 11998.80, 6000.00, 5998.80, "EURO", ""),
(41, "2024-03-15", "Europe", "Portugal", "Software", "Cloud Service",
80, 99.99, 7999.20, 4000.00, 3999.20, "", ""),
# Invalid currency code fallback test
(42, "2024-01-15", "Other", "Unknown", "Electronics", "Mystery Device",
25, 200.00, 5000.00, 3000.00, 2000.00, "XYZ", "?"),
]
# fmt: on
columns = [
"id",
"transaction_date",
"region",
"country",
"product_category",
"product_name",
"quantity",
"unit_price",
"revenue",
"cost",
"profit",
"currency_code",
"currency_symbol",
]
return pd.DataFrame(data, columns=columns)
def load_data(tbl_name: str, database: Database) -> None:
"""Load the international sales data into the database."""
pdf = get_international_sales_data()
pdf["transaction_date"] = pd.to_datetime(pdf["transaction_date"])
with database.get_sqla_engine() as engine:
schema = inspect(engine).default_schema_name
pdf.to_sql(
tbl_name,
engine,
schema=schema,
if_exists="replace",
chunksize=50,
dtype={
"id": Integer,
"transaction_date": Date,
"region": String(50),
"country": String(50),
"product_category": String(50),
"product_name": String(100),
"quantity": Integer,
"unit_price": Numeric(12, 2),
"revenue": Numeric(14, 2),
"cost": Numeric(14, 2),
"profit": Numeric(14, 2),
"currency_code": String(10),
"currency_symbol": String(10),
},
method="multi",
index=False,
)
logger.debug("Done loading international sales data!")
def load_international_sales(only_metadata: bool = False, force: bool = False) -> None:
"""Load international sales dataset for demonstrating dynamic currency formatting.
This dataset contains multi-currency transaction data with:
- Multiple currencies (USD, EUR, GBP, JPY, CAD, AUD)
- Case variations for normalization testing (usd, eur, Gbp, Cad)
- Word variations for normalization testing (euro, EURO)
- Symbol variations for normalization testing ()
- NULL and empty string currency values for fallback testing
- Invalid currency code (XYZ) for fallback testing
- Multiple monetary columns (revenue, cost, profit, unit_price)
"""
database = get_example_database()
tbl_name = "international_sales"
with database.get_sqla_engine() as engine:
schema = inspect(engine).default_schema_name
table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force):
load_data(tbl_name, database)
table = get_table_connector_registry()
obj = db.session.query(table).filter_by(table_name=tbl_name, schema=schema).first()
if not obj:
logger.debug("Creating table [%s] reference", tbl_name)
obj = table(table_name=tbl_name, schema=schema)
db.session.add(obj)
_set_table_metadata(obj, database)
def _set_table_metadata(datasource: SqlaTable, database: Database) -> None:
"""Set metadata for the international sales dataset."""
datasource.main_dttm_col = "transaction_date"
datasource.database = database
datasource.filter_select_enabled = True
datasource.description = (
"International sales transactions across multiple currencies "
"for demonstrating dynamic currency formatting features."
)
# Set the currency code column for dynamic currency detection
datasource.currency_code_column = "currency_code"
datasource.fetch_metadata()
+48 -31
View File
@@ -28,6 +28,7 @@ Future enhancements (to be added in separate PRs):
"""
import logging
from contextlib import AbstractContextManager
from typing import Any, Callable, TYPE_CHECKING, TypeVar
from flask import g
@@ -171,12 +172,12 @@ def _cleanup_session_finally() -> None:
logger.warning("Error in finally block: %s", e)
def mcp_auth_hook(tool_func: F) -> F:
def mcp_auth_hook(tool_func: F) -> F: # noqa: C901
"""
Authentication and authorization decorator for MCP tools.
This decorator assumes Flask application context and g.user
have already been set by WorkspaceContextMiddleware.
This decorator pushes Flask application context and sets up g.user
for MCP tool execution.
Supports both sync and async tool functions.
@@ -184,31 +185,46 @@ def mcp_auth_hook(tool_func: F) -> F:
TODO (future PR): Add JWT scope validation
TODO (future PR): Add comprehensive audit logging
"""
import contextlib
import functools
import inspect
import types
from flask import has_app_context
from superset.mcp_service.flask_singleton import get_flask_app
def _get_app_context_manager() -> AbstractContextManager[None]:
"""Return app context manager only if not already in one."""
if has_app_context():
# Already in app context (e.g., in tests), use null context
return contextlib.nullcontext()
# Push new app context for standalone MCP server
app = get_flask_app()
return app.app_context()
is_async = inspect.iscoroutinefunction(tool_func)
if is_async:
@functools.wraps(tool_func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
user = _setup_user_context()
with _get_app_context_manager():
user = _setup_user_context()
try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
user.username,
tool_func.__name__,
)
result = await tool_func(*args, **kwargs)
return result
except Exception:
_cleanup_session_on_error()
raise
finally:
_cleanup_session_finally()
try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
user.username,
tool_func.__name__,
)
result = await tool_func(*args, **kwargs)
return result
except Exception:
_cleanup_session_on_error()
raise
finally:
_cleanup_session_finally()
wrapper = async_wrapper
@@ -216,21 +232,22 @@ def mcp_auth_hook(tool_func: F) -> F:
@functools.wraps(tool_func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
user = _setup_user_context()
with _get_app_context_manager():
user = _setup_user_context()
try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
user.username,
tool_func.__name__,
)
result = tool_func(*args, **kwargs)
return result
except Exception:
_cleanup_session_on_error()
raise
finally:
_cleanup_session_finally()
try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
user.username,
tool_func.__name__,
)
result = tool_func(*args, **kwargs)
return result
except Exception:
_cleanup_session_on_error()
raise
finally:
_cleanup_session_finally()
wrapper = sync_wrapper
@@ -161,6 +161,23 @@ async def get_chart_data( # noqa: C901
or form_data.get("row_limit")
or current_app.config["ROW_LIMIT"]
)
# Handle different chart types that have different form_data structures
# Some charts use "metric" (singular), not "metrics" (plural):
# - big_number, big_number_total
# - pop_kpi (BigNumberPeriodOverPeriod)
# These charts also don't have groupby columns
viz_type = chart.viz_type or ""
if viz_type in ("big_number", "big_number_total", "pop_kpi"):
# These chart types use "metric" (singular)
metric = form_data.get("metric")
metrics = [metric] if metric else []
groupby_columns: list[str] = [] # These charts don't group by
else:
# Standard charts use "metrics" (plural) and "groupby"
metrics = form_data.get("metrics", [])
groupby_columns = form_data.get("groupby", [])
query_context = factory.create(
datasource={
"id": chart.datasource_id,
@@ -169,8 +186,8 @@ async def get_chart_data( # noqa: C901
queries=[
{
"filters": form_data.get("filters", []),
"columns": form_data.get("groupby", []),
"metrics": form_data.get("metrics", []),
"columns": groupby_columns,
"metrics": metrics,
"row_limit": row_limit,
"order_desc": True,
}
+14 -11
View File
@@ -137,17 +137,20 @@ async def list_charts(request: ListChartsRequest, ctx: Context) -> ChartList:
% (count, total_pages)
)
# Apply field filtering via serialization context
# Use columns_requested from result (already resolved by ModelListCore)
columns_to_filter = result.columns_requested
await ctx.debug(
"Applying field filtering via serialization context: select_columns=%s"
% (columns_to_filter,)
)
filtered = result.model_dump(
mode="json", context={"select_columns": columns_to_filter}
)
return ChartList.model_validate(filtered)
# Apply field filtering via serialization context if select_columns specified
# This triggers ChartInfo._filter_fields_by_context for each chart
if request.select_columns:
await ctx.debug(
"Applying field filtering via serialization context: select_columns=%s"
% (request.select_columns,)
)
# Return dict with context - FastMCP handles serialization
return result.model_dump(
mode="json", context={"select_columns": request.select_columns}
)
# No filtering - return full result as dict
return result.model_dump(mode="json")
except Exception as e:
await ctx.error("Failed to list charts: %s" % (str(e),))
raise
@@ -75,22 +75,16 @@ class RuntimeValidator:
warnings.extend(type_warnings)
suggestions.extend(type_suggestions)
# If we have warnings, return them as a validation error
# Semantic warnings are informational, not blocking errors.
# Log them for debugging but allow chart generation to proceed.
if warnings:
from superset.mcp_service.utils.error_builder import (
ChartErrorBuilder,
)
return False, ChartErrorBuilder.build_error(
error_type="runtime_semantic_warning",
template_key="performance_warning",
template_vars={
"reason": "; ".join(warnings[:3])
+ ("..." if len(warnings) > 3 else "")
},
custom_suggestions=suggestions[:5], # Limit suggestions
error_code="RUNTIME_SEMANTIC_WARNING",
logger.info(
"Runtime semantic warnings for dataset %s: %s",
dataset_id,
"; ".join(warnings[:3]) + ("..." if len(warnings) > 3 else ""),
)
if suggestions:
logger.info("Suggestions: %s", "; ".join(suggestions[:3]))
return True, None
@@ -139,14 +139,17 @@ async def list_dashboards(
% (count, total_pages)
)
# Apply field filtering via serialization context
# Use columns_requested from result (already resolved by ModelListCore)
columns_to_filter = result.columns_requested
await ctx.debug(
"Applying field filtering via serialization context: select_columns=%s"
% (columns_to_filter,)
)
filtered = result.model_dump(
mode="json", context={"select_columns": columns_to_filter}
)
return DashboardList.model_validate(filtered)
# Apply field filtering via serialization context if select_columns specified
# This triggers DashboardInfo._filter_fields_by_context for each dashboard
if request.select_columns:
await ctx.debug(
"Applying field filtering via serialization context: select_columns=%s"
% (request.select_columns,)
)
# Return dict with context - FastMCP handles serialization
return result.model_dump(
mode="json", context={"select_columns": request.select_columns}
)
# No filtering - return full result as dict
return result.model_dump(mode="json")

Some files were not shown because too many files have changed in this diff Show More