mirror of
https://github.com/apache/superset.git
synced 2026-09-11 17:54:25 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e6bd6597 | ||
|
|
1a8bfca18e | ||
|
|
bc76ab4eec | ||
|
|
2900258e05 | ||
|
|
2e29e33dd8 | ||
|
|
39238ef8a9 | ||
|
|
476e454384 | ||
|
|
4d462c76bd | ||
|
|
a06e6eb680 | ||
|
|
cee5ce13e0 | ||
|
|
6453980d8d | ||
|
|
f984dca5cc | ||
|
|
a77c2d550c | ||
|
|
f00f7d1c18 | ||
|
|
33ff127370 | ||
|
|
b941be01cf | ||
|
|
f4474b2e3e | ||
|
|
896947c787 | ||
|
|
4b1d92e575 | ||
|
|
2bcb66c2fc | ||
|
|
d0783da3e5 | ||
|
|
4532ccf638 | ||
|
|
c30edaf075 | ||
|
|
54f19856de | ||
|
|
ab8df1ab34 | ||
|
|
9555798d37 | ||
|
|
95c14b1fc1 | ||
|
|
b142f1956f | ||
|
|
e071e0c5a4 | ||
|
|
129b8e10a2 | ||
|
|
82d74d15ec | ||
|
|
89380638b0 | ||
|
|
c6ad0dbd3a | ||
|
|
f69cd43bd0 | ||
|
|
4c267b7ee2 | ||
|
|
7f6cdc5616 | ||
|
|
db61e4f62a | ||
|
|
68e917c3f6 | ||
|
|
96a3f2a187 | ||
|
|
c867d9379f |
@@ -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
|
||||
|
||||
Executable
+35
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Generated
+250
-178
@@ -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": "*",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
@@ -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",
|
||||
|
||||
+3
-2
@@ -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>
|
||||
);
|
||||
|
||||
+248
@@ -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);
|
||||
|
||||
+2
-8
@@ -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}$/;
|
||||
+81
-7
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -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', () => {
|
||||
|
||||
+31
@@ -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');
|
||||
});
|
||||
|
||||
+33
@@ -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();
|
||||
});
|
||||
+32
@@ -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);
|
||||
});
|
||||
+34
@@ -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',
|
||||
|
||||
+11
-2
@@ -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 = {
|
||||
|
||||
+14
-2
@@ -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 =
|
||||
|
||||
+10
-1
@@ -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);
|
||||
|
||||
+40
-14
@@ -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('£');
|
||||
});
|
||||
|
||||
+132
@@ -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,
|
||||
|
||||
+4
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+54
-2
@@ -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;
|
||||
|
||||
+189
@@ -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,
|
||||
|
||||
+120
-63
@@ -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 =>
|
||||
|
||||
+31
-13
@@ -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 () => {
|
||||
|
||||
+67
@@ -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';
|
||||
|
||||
+115
@@ -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(() => {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
};
|
||||
|
||||
+38
@@ -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);
|
||||
});
|
||||
+70
-12
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+21
-21
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user