mirror of
https://github.com/apache/superset.git
synced 2026-08-29 19:41:17 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c18c91a89d | ||
|
|
96119ffefc | ||
|
|
74ea780eaa | ||
|
|
3d91ba3fde | ||
|
|
9151b5c6fb | ||
|
|
ec19a37e9b | ||
|
|
2ca3695f30 | ||
|
|
ac88acb14b | ||
|
|
46f17aa880 | ||
|
|
7bc44d5504 | ||
|
|
4444a89f26 | ||
|
|
7c2a3fa777 | ||
|
|
b38fa70256 | ||
|
|
1c8d58a77b | ||
|
|
de33efae98 | ||
|
|
b30d569028 | ||
|
|
6f69fc6dba | ||
|
|
b2a8842551 | ||
|
|
c7cc22f4fe | ||
|
|
058eaf78ab | ||
|
|
30402b412c | ||
|
|
782a57fbfc | ||
|
|
99a1f756cd | ||
|
|
0eda633b45 | ||
|
|
5c24f72d92 | ||
|
|
60479fb958 | ||
|
|
4ced5ca35a | ||
|
|
fc26991cd4 | ||
|
|
a5c68c8df9 | ||
|
|
53e76afd70 | ||
|
|
d997d363e3 | ||
|
|
9a6f6ee0c0 | ||
|
|
94dd3d049c | ||
|
|
b3f718da62 | ||
|
|
abf338d611 |
@@ -24,14 +24,6 @@ updates:
|
||||
- dependency-name: "@types/react-dom"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "react-icons"
|
||||
# JSDOM v30 doesn't play well with Jest v30
|
||||
# Source: https://jestjs.io/blog#known-issues
|
||||
# GH thread: https://github.com/jsdom/jsdom/issues/3492
|
||||
- dependency-name: "jest-environment-jsdom"
|
||||
# `@swc/plugin-transform-imports` doesn't work with current Webpack-SWC hybrid setup
|
||||
# See https://github.com/apache/superset/pull/37384#issuecomment-3793991389
|
||||
# TODO: remove the plugin once Lodash usage has been migrated to a more readily tree-shakeable alternative
|
||||
- dependency-name: "@swc/plugin-transform-imports"
|
||||
# deck.gl and luma.gl share strict peer constraints across the root and
|
||||
# plugin workspaces, and root overrides pin their transitive versions.
|
||||
# Upgrade both families together in a manually validated change.
|
||||
@@ -87,6 +79,11 @@ updates:
|
||||
patterns:
|
||||
- "ag-grid-react"
|
||||
- "ag-grid-community"
|
||||
swc:
|
||||
patterns:
|
||||
- "@swc/core"
|
||||
- "@swc/plugin-emotion"
|
||||
- "@swc/plugin-transform-imports"
|
||||
open-pull-requests-limit: 30
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
|
||||
@@ -66,6 +66,27 @@ jobs:
|
||||
|
||||
- name: "Set up liccheck"
|
||||
run: |
|
||||
# liccheck (as of 0.9.2) still does a bare `import pkg_resources`
|
||||
# without declaring setuptools as a dependency, relying on it
|
||||
# having historically been bundled. setuptools 81+ (installed
|
||||
# above via requirements/base.txt) dropped the pkg_resources
|
||||
# subpackage entirely, so liccheck's own import breaks outright.
|
||||
#
|
||||
# Reinstalling an older setuptools would restore pkg_resources but
|
||||
# would also downgrade the *real* setuptools install, which then
|
||||
# trips liccheck's own working_set.resolve() -- it cross-checks
|
||||
# requirements/base.txt's declared `setuptools==84.0.0` against
|
||||
# what's actually installed, and a downgrade makes those disagree.
|
||||
#
|
||||
# Instead, vendor just the pkg_resources/ package files from an
|
||||
# old setuptools wheel into site-packages, leaving the real
|
||||
# setuptools install (and its dist-info metadata) untouched. This
|
||||
# gives liccheck an importable pkg_resources whose own working-set
|
||||
# scan still correctly reports the real installed setuptools
|
||||
# version, so no conflict is raised.
|
||||
pip download "setuptools<81" --no-deps -d /tmp/old-setuptools
|
||||
python -m zipfile -e /tmp/old-setuptools/setuptools-*.whl /tmp/old-setuptools-extracted/
|
||||
cp -r /tmp/old-setuptools-extracted/pkg_resources "$(python -c 'import site; print(site.getsitepackages()[0])')/"
|
||||
uv pip install --system liccheck
|
||||
- name: "Run liccheck"
|
||||
run: |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
.stylelintignore
|
||||
.flake8
|
||||
.nvmrc
|
||||
.npmrc
|
||||
.rat-excludes
|
||||
.swcrc
|
||||
.*log
|
||||
|
||||
@@ -493,8 +493,8 @@ Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in or
|
||||
|
||||
First, be sure you are using the following versions of Node.js and npm:
|
||||
|
||||
- `Node.js`: Version 22 (LTS)
|
||||
- `npm`: Version 10
|
||||
- `Node.js`: Version 24 (see `superset-frontend/.nvmrc` for the exact version)
|
||||
- `npm`: Version 11
|
||||
|
||||
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage your node environment:
|
||||
|
||||
@@ -507,8 +507,8 @@ export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
|
||||
|
||||
cd superset-frontend
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm install
|
||||
nvm use
|
||||
```
|
||||
|
||||
Or if you use the default macOS starting with Catalina shell `zsh`, try:
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.16.1",
|
||||
"antd": "^6.6.1",
|
||||
"baseline-browser-mapping": "^2.11.16",
|
||||
"baseline-browser-mapping": "^2.11.17",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
@@ -76,7 +76,7 @@
|
||||
"react-svg-pan-zoom": "^3.13.1",
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"reselect": "^5.3.0",
|
||||
"storybook": "^10.5.10",
|
||||
"swagger-ui-react": "^5.32.14",
|
||||
"swc-loader": "^0.2.7",
|
||||
|
||||
+11
-11
@@ -6529,10 +6529,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.16, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.16"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz#0fa19a4ece2e34439ecaa3fdca8a59acfbd287fb"
|
||||
integrity sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.17, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.17"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz#a168205490077c5d7c542f1610016af4ade8f875"
|
||||
integrity sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -6614,9 +6614,9 @@ boxen@^7.0.0:
|
||||
wrap-ansi "^8.1.0"
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.15.tgz#a6d90d54067236e5f42570a3b7378d594d9b7738"
|
||||
integrity sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==
|
||||
version "1.1.18"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.18.tgz#3ce74d89885136be1535341f8c3d4425c29a5cab"
|
||||
integrity sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
@@ -14106,10 +14106,10 @@ reselect@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524"
|
||||
integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==
|
||||
|
||||
reselect@^5.1.0, reselect@^5.1.1, reselect@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1"
|
||||
integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==
|
||||
reselect@^5.1.0, reselect@^5.1.1, reselect@^5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.3.0.tgz#0a3e3ed4436bdf2ab7c5e0f392dab2c062595d61"
|
||||
integrity sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==
|
||||
|
||||
resize-observer-polyfill@1.5.1:
|
||||
version "1.5.1"
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=40.9.0", "wheel"]
|
||||
requires = ["setuptools>=84.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
@@ -67,7 +67,7 @@ dependencies = [
|
||||
"flask-sqlalchemy>=3.1.1, <4.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet<=3.5.4, >=3.5.4",
|
||||
"greenlet<=3.5.5, >=3.5.5",
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
|
||||
@@ -52,11 +52,11 @@ marshmallow-sqlalchemy>=1.5.0
|
||||
# needed for python 3.12 support
|
||||
openapi-schema-validator>=0.6.3
|
||||
|
||||
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
|
||||
# Pin setuptools <85 until all dependencies migrate from pkg_resources to importlib.metadata
|
||||
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
|
||||
# Known affected packages: Preset's 'clients' package
|
||||
# See docs/docs/contributing/pkg-resources-migration.md for details
|
||||
setuptools<81
|
||||
setuptools<85
|
||||
|
||||
# google-auth 2.53+ dropped its transitive dependency on cachetools, which is
|
||||
# imported directly by superset.db_engine_specs.aws_iam. We declare cachetools
|
||||
|
||||
@@ -163,7 +163,7 @@ google-auth==2.53.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# shillelagh
|
||||
greenlet==3.5.4
|
||||
greenlet==3.5.5
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
@@ -366,7 +366,7 @@ rpds-py==0.25.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
setuptools==80.9.0
|
||||
setuptools==84.0.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
|
||||
@@ -375,7 +375,7 @@ googleapis-common-protos==1.66.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
greenlet==3.5.4
|
||||
greenlet==3.5.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -663,7 +663,7 @@ pillow==12.3.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# matplotlib
|
||||
pip==25.1.1
|
||||
pip==26.2.1
|
||||
# via apache-superset
|
||||
platformdirs==4.3.8
|
||||
# via
|
||||
@@ -920,7 +920,7 @@ secretstorage==3.5.0
|
||||
# via keyring
|
||||
semver==3.0.4
|
||||
# via apache-superset-extensions-cli
|
||||
setuptools==80.9.0
|
||||
setuptools==84.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# nodeenv
|
||||
|
||||
@@ -390,19 +390,3 @@ def get_session() -> scoped_session:
|
||||
:returns: The SQLAlchemy scoped session instance.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Dataset",
|
||||
"Database",
|
||||
"Chart",
|
||||
"Dashboard",
|
||||
"User",
|
||||
"Role",
|
||||
"Group",
|
||||
"Tag",
|
||||
"KeyValue",
|
||||
"Subject",
|
||||
"CoreModel",
|
||||
"get_session",
|
||||
]
|
||||
|
||||
@@ -183,10 +183,3 @@ def prompt(
|
||||
"MCP prompt decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"tool",
|
||||
"prompt",
|
||||
"ToolAnnotations",
|
||||
]
|
||||
|
||||
@@ -55,9 +55,3 @@ class SavedQueryDAO(BaseDAO[SavedQuery]):
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QueryDAO",
|
||||
"SavedQueryDAO",
|
||||
]
|
||||
|
||||
@@ -71,9 +71,3 @@ class SavedQuery(CoreModel):
|
||||
database_id: int | None
|
||||
description: str | None
|
||||
user_id: int | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Query",
|
||||
"SavedQuery",
|
||||
]
|
||||
|
||||
@@ -46,6 +46,3 @@ def get_sqlglot_dialect(database: "Database") -> Dialects:
|
||||
:returns: The SQLGlot dialect enum corresponding to the database.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = ["get_sqlglot_dialect"]
|
||||
|
||||
@@ -165,13 +165,3 @@ class AsyncQueryHandle:
|
||||
:returns: True if cancellation was successful
|
||||
"""
|
||||
raise NotImplementedError("Method will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QueryStatus",
|
||||
"QueryOptions",
|
||||
"QueryResult",
|
||||
"StatementResult",
|
||||
"AsyncQueryHandle",
|
||||
"CacheOptions",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,3 @@ class RestApi(BaseApi):
|
||||
"""
|
||||
|
||||
allow_browser_login = True
|
||||
|
||||
|
||||
__all__ = ["RestApi"]
|
||||
|
||||
@@ -98,6 +98,3 @@ def api(
|
||||
"API decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["api"]
|
||||
|
||||
@@ -164,6 +164,3 @@ class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
|
||||
:return: SemanticViewModel instance or None
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
|
||||
|
||||
@@ -97,6 +97,3 @@ def semantic_layer(
|
||||
"Semantic layer decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["semantic_layer"]
|
||||
|
||||
@@ -21,6 +21,7 @@ from abc import ABC, abstractmethod
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from superset_core.semantic_layers.view import SemanticView
|
||||
|
||||
ConfigT = TypeVar("ConfigT", bound=BaseModel)
|
||||
|
||||
@@ -80,6 +80,3 @@ class SemanticViewModel(CoreModel):
|
||||
semantic_layer_uuid: UUID
|
||||
created_on: datetime | None
|
||||
changed_on: datetime | None
|
||||
|
||||
|
||||
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
|
||||
|
||||
@@ -71,6 +71,3 @@ class TaskDAO(BaseDAO[Task]):
|
||||
:returns: Task instance or None if not found or not active
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = ["TaskDAO"]
|
||||
|
||||
@@ -144,9 +144,3 @@ def get_context() -> TaskContext:
|
||||
)
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"task",
|
||||
"get_context",
|
||||
]
|
||||
|
||||
@@ -161,9 +161,3 @@ class TaskSubscriber(CoreModel):
|
||||
changed_on: datetime | None
|
||||
created_by_fk: int | None
|
||||
changed_by_fk: int | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Task",
|
||||
"TaskSubscriber",
|
||||
]
|
||||
|
||||
@@ -226,12 +226,3 @@ class TaskContext(ABC):
|
||||
cleanup_partial_work()
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TaskStatus",
|
||||
"TaskScope",
|
||||
"TaskProperties",
|
||||
"TaskContext",
|
||||
"TaskOptions",
|
||||
]
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../superset-frontend/.npmrc
|
||||
@@ -0,0 +1 @@
|
||||
min-release-age=3
|
||||
+54
-103
@@ -2086,14 +2086,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
@@ -2107,18 +2099,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.15.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
|
||||
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
@@ -3215,9 +3195,9 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -3834,9 +3814,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
|
||||
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "1.4.5",
|
||||
@@ -4317,18 +4301,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
@@ -4854,9 +4826,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -5586,9 +5558,19 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -7848,11 +7830,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
@@ -8015,9 +7992,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -9842,7 +9819,7 @@
|
||||
"debug": "4.4.0",
|
||||
"execa": "4.1.0",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.3.1",
|
||||
"nyc": "15.1.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
},
|
||||
@@ -10121,7 +10098,7 @@
|
||||
"requires": {
|
||||
"@eslint/object-schema": "^3.0.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.4"
|
||||
"minimatch": ">=10"
|
||||
}
|
||||
},
|
||||
"@eslint/config-helpers": {
|
||||
@@ -10213,18 +10190,10 @@
|
||||
"camelcase": "^5.3.1",
|
||||
"find-up": "^4.1.0",
|
||||
"get-package-type": "^0.1.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.3.1",
|
||||
"resolve-from": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"requires": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
@@ -10234,14 +10203,6 @@
|
||||
"path-exists": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
|
||||
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
|
||||
"requires": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
@@ -11185,9 +11146,9 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
@@ -11630,9 +11591,9 @@
|
||||
}
|
||||
},
|
||||
"d3-color": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
|
||||
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
|
||||
},
|
||||
"d3-format": {
|
||||
"version": "1.4.5",
|
||||
@@ -11644,7 +11605,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
|
||||
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
|
||||
"requires": {
|
||||
"d3-color": "1"
|
||||
"d3-color": "3.1.0"
|
||||
}
|
||||
},
|
||||
"d3-scale": {
|
||||
@@ -11892,7 +11853,7 @@
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.4",
|
||||
"minimatch": ">=10",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
@@ -11981,11 +11942,6 @@
|
||||
"eslint-visitor-keys": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
|
||||
},
|
||||
"esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
@@ -12334,7 +12290,7 @@
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"minimatch": "<10",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
@@ -12345,9 +12301,9 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -12358,7 +12314,7 @@
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "1.1.18"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12852,9 +12808,9 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"requires": {
|
||||
"argparse": "^2.0.1"
|
||||
}
|
||||
@@ -13435,7 +13391,7 @@
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
"brace-expansion": ">=5.0.9"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
@@ -14383,11 +14339,6 @@
|
||||
"which": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
||||
},
|
||||
"sshpk": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
@@ -14494,7 +14445,7 @@
|
||||
"requires": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^7.1.4",
|
||||
"minimatch": "^3.0.4"
|
||||
"minimatch": "<10"
|
||||
},
|
||||
"dependencies": {
|
||||
"balanced-match": {
|
||||
@@ -14503,9 +14454,9 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -14516,7 +14467,7 @@
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "1.1.18"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,20 @@
|
||||
"overrides": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"@cypress/code-coverage": {
|
||||
"js-yaml": "4.1.1"
|
||||
"js-yaml": "4.3.1"
|
||||
},
|
||||
"@cypress/request": "^3.0.0",
|
||||
"cypress": {
|
||||
"form-data": "^2.3.4"
|
||||
},
|
||||
"d3-interpolate": {
|
||||
"d3-color": "3.1.0"
|
||||
},
|
||||
"minimatch@<10": {
|
||||
"brace-expansion": "1.1.18"
|
||||
},
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
"brace-expansion": ">=5.0.9"
|
||||
},
|
||||
"qs": "^6.14.2",
|
||||
"uuid": "^11.1.1"
|
||||
|
||||
@@ -77,7 +77,7 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
Generated
+35
-35
@@ -84,7 +84,7 @@
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-disposition": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
@@ -218,13 +218,13 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.16",
|
||||
"baseline-browser-mapping": "^2.11.17",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -15753,9 +15753,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.16",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz",
|
||||
"integrity": "sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==",
|
||||
"version": "2.11.17",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz",
|
||||
"integrity": "sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -17451,12 +17451,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz",
|
||||
"integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -17804,9 +17804,9 @@
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -19858,9 +19858,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.8.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
|
||||
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -26738,9 +26738,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||
"version": "3.15.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
|
||||
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -27662,9 +27662,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lerna/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -30733,9 +30733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.23",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
||||
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -34750,9 +34750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-diff-viewer-continued/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -36063,9 +36063,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
|
||||
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
@@ -38445,9 +38445,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/stylelint/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -43010,7 +43010,7 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"reselect": "^5.2.0",
|
||||
"reselect": "^5.3.0",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"xss": "^1.0.15"
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-disposition": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
@@ -295,13 +295,13 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.16",
|
||||
"baseline-browser-mapping": "^2.11.17",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -383,6 +383,9 @@
|
||||
"@great-expectations/jsonforms-antd-renderers": {
|
||||
"antd": "$antd"
|
||||
},
|
||||
"@istanbuljs/load-nyc-config": {
|
||||
"js-yaml": "^3.15.1"
|
||||
},
|
||||
"@jest/globals": "^30.4.0",
|
||||
"@jest/types": "^30.4.0",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
@@ -392,7 +395,10 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"cosmiconfig": {
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"dompurify": "^3.4.13",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint-plugin-import": {
|
||||
"eslint": "$eslint"
|
||||
@@ -408,16 +414,22 @@
|
||||
"jest-mock": "^30.4.0",
|
||||
"jest-runtime": "^30.4.0",
|
||||
"jest-util": "^30.4.0",
|
||||
"js-yaml-loader": {
|
||||
"js-yaml": "^3.15.1"
|
||||
},
|
||||
"jspdf": "^4.2.0",
|
||||
"lerna": {
|
||||
"js-yaml": "^4.3.0"
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nanoid@>=3 <4": "3.3.18",
|
||||
"nwsapi": "^2.2.13",
|
||||
"nwsapi": "^2.2.24",
|
||||
"puppeteer": "^22.4.1",
|
||||
"react-diff-viewer-continued": {
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"tar": "^7.5.16",
|
||||
"typescript-json-schema": "^0.68.0",
|
||||
"underscore": "^1.13.7",
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"reselect": "^5.2.0",
|
||||
"reselect": "^5.3.0",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"xss": "^1.0.15"
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum VizType {
|
||||
BoxPlot = 'box_plot',
|
||||
Bubble = 'bubble_v2',
|
||||
Bullet = 'bullet',
|
||||
Butterfly = 'butterfly',
|
||||
Calendar = 'cal_heatmap',
|
||||
Cartodiagram = 'cartodiagram',
|
||||
Chord = 'chord',
|
||||
|
||||
+14
-4
@@ -38,11 +38,21 @@ function formatMemory(
|
||||
: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB'];
|
||||
const base = binary ? 1024 : 1000;
|
||||
|
||||
const i = Math.min(
|
||||
suffixes.length - 1,
|
||||
Math.floor(Math.log(absValue) / Math.log(base)),
|
||||
let i = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
suffixes.length - 1,
|
||||
Math.floor(Math.log(absValue) / Math.log(base)),
|
||||
),
|
||||
);
|
||||
formatted = `${sign}${parseFloat((absValue / Math.pow(base, i)).toFixed(decimals))}${suffixes[i]}`;
|
||||
let scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
|
||||
|
||||
if (scaled >= base && i < suffixes.length - 1) {
|
||||
i += 1;
|
||||
scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
|
||||
}
|
||||
|
||||
formatted = `${sign}${scaled}${suffixes[i]}`;
|
||||
}
|
||||
|
||||
if (transfer) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
QueryFormData,
|
||||
} from '../query';
|
||||
import type { JsonResponse } from '../connection';
|
||||
import type { MenuItem } from '../components/Menu';
|
||||
|
||||
/**
|
||||
* A function which returns text (or marked-up text)
|
||||
@@ -164,6 +165,13 @@ export interface SliceHeaderExtension {
|
||||
dashboardId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for extensions to the Slice Header more-options menu
|
||||
*/
|
||||
export interface SliceHeaderMenuExtension extends SliceHeaderExtension {
|
||||
sliceName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for extensions to Embed Modal
|
||||
*/
|
||||
@@ -262,6 +270,9 @@ export type Extensions = Partial<{
|
||||
'sqleditor.extension.form': ComponentType<SQLFormExtensionProps>;
|
||||
'sqleditor.extension.resultTable': ComponentType<SQLResultTableExtensionProps>;
|
||||
'dashboard.slice.header': ComponentType<SliceHeaderExtension>;
|
||||
'dashboard.slice.header.menu': (
|
||||
context: SliceHeaderMenuExtension,
|
||||
) => MenuItem[];
|
||||
'sqleditor.extension.customAutocomplete': (
|
||||
args: CustomAutoCompleteArgs,
|
||||
) => CustomAutocomplete[] | undefined;
|
||||
|
||||
+25
@@ -60,6 +60,31 @@ test('formats float bytes in human readable format with default options', () =>
|
||||
expect(formatter(1200.666)).toBe('1.2kB');
|
||||
});
|
||||
|
||||
test('formats values below one byte without dropping the unit', () => {
|
||||
const formatter = createMemoryFormatter();
|
||||
expect(formatter(0.5)).toBe('0.5B');
|
||||
expect(formatter(0.004)).toBe('0B');
|
||||
expect(formatter(-0.25)).toBe('-0.25B');
|
||||
|
||||
const binaryFormatter = createMemoryFormatter({ binary: true });
|
||||
expect(binaryFormatter(0.5)).toBe('0.5B');
|
||||
});
|
||||
|
||||
test('rolls over to the next unit when rounding reaches the base', () => {
|
||||
const formatter = createMemoryFormatter();
|
||||
expect(formatter(999999)).toBe('1MB');
|
||||
expect(formatter(999995)).toBe('1MB');
|
||||
expect(formatter(999994)).toBe('999.99kB');
|
||||
expect(formatter(-999999)).toBe('-1MB');
|
||||
|
||||
const binaryFormatter = createMemoryFormatter({ binary: true });
|
||||
expect(binaryFormatter(1024 * 1024 - 1)).toBe('1MiB');
|
||||
|
||||
// the largest unit has nothing to roll over into
|
||||
const largest = createMemoryFormatter();
|
||||
expect(largest(Math.pow(1000, 11))).toBe('1000QB');
|
||||
});
|
||||
|
||||
test('formats bytes in human readable format with additional binary option', () => {
|
||||
const formatter = createMemoryFormatter({ binary: true });
|
||||
expect(formatter(0)).toBe('0B');
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import Echart from '../components/Echart';
|
||||
import { ButterflyTransformedProps } from './types';
|
||||
import { EventHandlers } from '../types';
|
||||
|
||||
export default function Butterfly(props: ButterflyTransformedProps) {
|
||||
const { height, width, echartOptions, refs, onLegendStateChanged, formData } =
|
||||
props;
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
legendselectchanged: payload => {
|
||||
onLegendStateChanged?.(payload.selected);
|
||||
},
|
||||
legendselectall: payload => {
|
||||
onLegendStateChanged?.(payload.selected);
|
||||
},
|
||||
legendinverseselect: payload => {
|
||||
onLegendStateChanged?.(payload.selected);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Echart
|
||||
refs={refs}
|
||||
height={height}
|
||||
width={width}
|
||||
echartOptions={echartOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
vizType={formData.vizType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
QueryFormData,
|
||||
QueryFormOrderBy,
|
||||
} from '@superset-ui/core';
|
||||
import { buildSortMetricOrderby } from '@superset-ui/chart-controls';
|
||||
|
||||
export default function buildQuery(formData: QueryFormData) {
|
||||
const columns = ensureIsArray(formData.groupby);
|
||||
const baseMetrics = [
|
||||
...ensureIsArray(formData.left_metric),
|
||||
...ensureIsArray(formData.right_metric),
|
||||
];
|
||||
|
||||
const { orderby, metrics } = buildSortMetricOrderby({
|
||||
metrics: baseMetrics,
|
||||
timeseriesLimitMetric: ensureIsArray(formData.orderby)[0],
|
||||
order_desc: formData.order_desc,
|
||||
});
|
||||
const resolvedOrderby: QueryFormOrderBy[] | undefined = orderby.length
|
||||
? orderby
|
||||
: columns.length
|
||||
? [[columns[0], true]]
|
||||
: undefined;
|
||||
return buildQueryContext(formData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
columns,
|
||||
metrics,
|
||||
orderby: resolvedOrderby,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_LEGEND_FORM_DATA,
|
||||
DEFAULT_TITLE_FORM_DATA,
|
||||
} from '../constants';
|
||||
import { defaultXAxis } from '../defaults';
|
||||
|
||||
export const DEFAULT_FORM_DATA = {
|
||||
...DEFAULT_LEGEND_FORM_DATA,
|
||||
...DEFAULT_TITLE_FORM_DATA,
|
||||
xAxisLabelRotation: defaultXAxis.xAxisLabelRotation,
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import {
|
||||
ControlPanelConfig,
|
||||
ControlSubSectionHeader,
|
||||
formatSelectOptions,
|
||||
getStandardizedControls,
|
||||
sections,
|
||||
sharedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import {
|
||||
legendSection,
|
||||
showValueControl,
|
||||
xAxisLabelRotation,
|
||||
} from '../controls';
|
||||
import { DEFAULT_FORM_DATA } from './constants';
|
||||
|
||||
const { xAxisTitleMargin, yAxisTitleMargin } = DEFAULT_FORM_DATA;
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: t('Query'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
['groupby'],
|
||||
[
|
||||
{
|
||||
name: 'left_metric',
|
||||
config: {
|
||||
...sharedControls.metric,
|
||||
label: t('Left metric'),
|
||||
description: t(
|
||||
'Metric displayed on the left side of the butterfly chart',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'right_metric',
|
||||
config: {
|
||||
...sharedControls.metric,
|
||||
label: t('Right metric'),
|
||||
description: t(
|
||||
'Metric displayed on the right side of the butterfly chart',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
['adhoc_filters'],
|
||||
['row_limit'],
|
||||
['orderby'],
|
||||
[
|
||||
{
|
||||
name: 'order_desc',
|
||||
config: {
|
||||
...sharedControls.order_desc,
|
||||
visibility: ({ controls }) => Boolean(controls.orderby.value),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Chart Options'),
|
||||
expanded: true,
|
||||
controlSetRows: [[showValueControl], ...legendSection],
|
||||
},
|
||||
{
|
||||
label: t('Series settings'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
<ControlSubSectionHeader>
|
||||
{t('Left series setting')}
|
||||
</ControlSubSectionHeader>,
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'left_color',
|
||||
config: {
|
||||
label: t('Left color'),
|
||||
type: 'ColorPickerControl',
|
||||
default: { r: 84, g: 112, b: 198, a: 1 },
|
||||
renderTrigger: true,
|
||||
description: t('Color for bars on the left side of the chart'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'left_label',
|
||||
config: {
|
||||
label: t('Left label'),
|
||||
type: 'TextControl',
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Customize the label for the left series in tooltips and legend',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
<ControlSubSectionHeader>
|
||||
{t('Right series setting')}
|
||||
</ControlSubSectionHeader>,
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'right_color',
|
||||
config: {
|
||||
label: t('Right color'),
|
||||
type: 'ColorPickerControl',
|
||||
default: { r: 145, g: 204, b: 117, a: 1 },
|
||||
renderTrigger: true,
|
||||
description: t('Color for bars on the right side of the chart'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'right_label',
|
||||
config: {
|
||||
label: t('Right label'),
|
||||
type: 'TextControl',
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Customize the label for the right series in tooltips and legend',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('X Axis'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'x_axis_label',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('X Axis Label'),
|
||||
renderTrigger: true,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'x_axis_title_margin',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('X Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: xAxisTitleMargin,
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
},
|
||||
},
|
||||
],
|
||||
['x_axis_format'],
|
||||
['currency_format'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Y Axis'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'y_axis_label',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Y Axis Label'),
|
||||
renderTrigger: true,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_title_margin',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('Y Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: yAxisTitleMargin,
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: xAxisLabelRotation.name,
|
||||
config: {
|
||||
...xAxisLabelRotation.config,
|
||||
label: t('Rotate category label'),
|
||||
description: t(
|
||||
'Input field supports custom rotation. e.g. 30 for 30°',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
groupby: {
|
||||
label: t('Categories'),
|
||||
description: t('Dimension used for category labels on the vertical axis'),
|
||||
multi: false,
|
||||
},
|
||||
},
|
||||
formDataOverrides: formData => ({
|
||||
...formData,
|
||||
groupby: ensureIsArray(getStandardizedControls().shiftColumn()),
|
||||
left_metric: getStandardizedControls().shiftMetric(),
|
||||
right_metric: getStandardizedControls().shiftMetric(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import buildQuery from './buildQuery';
|
||||
import controlPanel from './controlPanel';
|
||||
import transformProps from './transformProps';
|
||||
import { EchartsButterflyChartProps, EchartsButterflyFormData } from './types';
|
||||
|
||||
export default class EchartsButterflyChartPlugin extends ChartPlugin<
|
||||
EchartsButterflyFormData,
|
||||
EchartsButterflyChartProps
|
||||
> {
|
||||
constructor() {
|
||||
super({
|
||||
buildQuery,
|
||||
controlPanel,
|
||||
loadChart: () => import('./Butterfly'),
|
||||
metadata: new ChartMetadata({
|
||||
credits: ['https://echarts.apache.org'],
|
||||
category: t('Comparison'),
|
||||
description: t(
|
||||
'A butterfly chart compares two metrics across categories using horizontal bars ' +
|
||||
'that extend left and right from a central axis.',
|
||||
),
|
||||
name: t('Butterfly Chart'),
|
||||
tags: [
|
||||
t('Categorical'),
|
||||
t('Comparison'),
|
||||
t('ECharts'),
|
||||
t('Multi-Variables'),
|
||||
],
|
||||
thumbnail: '',
|
||||
}),
|
||||
transformProps,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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,
|
||||
ensureIsArray,
|
||||
getColumnLabel,
|
||||
getMetricLabel,
|
||||
getNumberFormatter,
|
||||
NumberFormatter,
|
||||
rgbToHex,
|
||||
tooltipHtml,
|
||||
} from '@superset-ui/core';
|
||||
import type { ComposeOption } from 'echarts/core';
|
||||
import type { BarSeriesOption } from 'echarts/charts';
|
||||
import type { CallbackDataParams } from 'echarts/types/src/util/types';
|
||||
import { EchartsButterflyChartProps, ButterflyTransformedProps } from './types';
|
||||
import { DEFAULT_FORM_DATA } from './constants';
|
||||
import { defaultGrid } from '../defaults';
|
||||
import { getDefaultTooltip } from '../utils/tooltip';
|
||||
import { Refs } from '../types';
|
||||
import { NULL_STRING } from '../constants';
|
||||
import { getChartPadding, getLegendProps } from '../utils/series';
|
||||
import { resolveLegendLayout } from '../utils/legendLayout';
|
||||
import { convertInteger } from '../utils/convertInteger';
|
||||
|
||||
type EChartsOption = ComposeOption<BarSeriesOption>;
|
||||
|
||||
const LABEL_LEFT = { position: 'left' as const };
|
||||
const LABEL_RIGHT = { position: 'right' as const };
|
||||
|
||||
function formatCategory(value: unknown): string {
|
||||
if (value == null) {
|
||||
return NULL_STRING;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatTooltip(
|
||||
params: CallbackDataParams[],
|
||||
formatter: NumberFormatter | CurrencyFormatter,
|
||||
) {
|
||||
const axisParams = params.filter(
|
||||
param => param.seriesName && typeof param.value === 'number',
|
||||
);
|
||||
if (!axisParams.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const title = axisParams[0].name;
|
||||
const rows = axisParams.map(param => [
|
||||
param.seriesName!,
|
||||
formatter(Math.abs(param.value as number)),
|
||||
]);
|
||||
|
||||
return tooltipHtml(rows, title);
|
||||
}
|
||||
|
||||
export default function transformProps(
|
||||
chartProps: EchartsButterflyChartProps,
|
||||
): ButterflyTransformedProps {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
formData,
|
||||
legendState,
|
||||
queriesData,
|
||||
hooks,
|
||||
theme,
|
||||
inContextMenu,
|
||||
} = chartProps;
|
||||
const refs: Refs = {};
|
||||
const { data = [] } = queriesData[0];
|
||||
const { setDataMask = () => {}, onContextMenu, onLegendStateChanged } = hooks;
|
||||
|
||||
const {
|
||||
currencyFormat,
|
||||
groupby,
|
||||
leftMetric,
|
||||
rightMetric,
|
||||
leftColor = { r: 84, g: 112, b: 198, a: 1 },
|
||||
rightColor = { r: 145, g: 204, b: 117, a: 1 },
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
xAxisFormat,
|
||||
xAxisTitleMargin,
|
||||
yAxisTitleMargin,
|
||||
showLegend,
|
||||
legendMargin,
|
||||
legendOrientation,
|
||||
legendType,
|
||||
legendSort,
|
||||
showValue,
|
||||
xAxisLabelRotation,
|
||||
}: EchartsButterflyChartProps['formData'] = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
...formData,
|
||||
};
|
||||
|
||||
const groupbyColumn = ensureIsArray(groupby)[0];
|
||||
const categoryLabel = getColumnLabel(groupbyColumn);
|
||||
const leftMetricLabel = leftMetric ? getMetricLabel(leftMetric) : '';
|
||||
const rightMetricLabel = rightMetric ? getMetricLabel(rightMetric) : '';
|
||||
const leftSeriesName = leftLabel || leftMetricLabel;
|
||||
const rightSeriesName = rightLabel || rightMetricLabel;
|
||||
|
||||
const defaultFormatter = currencyFormat?.symbol
|
||||
? new CurrencyFormatter({ d3Format: xAxisFormat, currency: currencyFormat })
|
||||
: getNumberFormatter(xAxisFormat);
|
||||
|
||||
const categories = data.map(row => formatCategory(row[categoryLabel]));
|
||||
const leftData = data.map(row => {
|
||||
const value = Number(row[leftMetricLabel] ?? 0);
|
||||
return {
|
||||
value: -Math.abs(value),
|
||||
label: LABEL_LEFT,
|
||||
};
|
||||
});
|
||||
const rightData = data.map(row => {
|
||||
const value = Number(row[rightMetricLabel] ?? 0);
|
||||
return {
|
||||
value: Math.abs(value),
|
||||
label: LABEL_RIGHT,
|
||||
};
|
||||
});
|
||||
|
||||
const labelFormatter = (params: CallbackDataParams) => {
|
||||
const value = Math.abs(params.value as number);
|
||||
|
||||
if (value === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return defaultFormatter(value);
|
||||
};
|
||||
|
||||
const series: BarSeriesOption[] = [
|
||||
{
|
||||
name: leftSeriesName,
|
||||
type: 'bar',
|
||||
stack: 'Total',
|
||||
label: {
|
||||
show: showValue,
|
||||
formatter: labelFormatter,
|
||||
color: theme.colorText,
|
||||
},
|
||||
itemStyle: {
|
||||
color: rgbToHex(leftColor.r, leftColor.g, leftColor.b),
|
||||
},
|
||||
data: leftData,
|
||||
},
|
||||
{
|
||||
name: rightSeriesName,
|
||||
type: 'bar',
|
||||
stack: 'Total',
|
||||
label: {
|
||||
show: showValue,
|
||||
formatter: labelFormatter,
|
||||
color: theme.colorText,
|
||||
},
|
||||
itemStyle: {
|
||||
color: rgbToHex(rightColor.r, rightColor.g, rightColor.b),
|
||||
},
|
||||
data: rightData,
|
||||
},
|
||||
];
|
||||
|
||||
const legendData = [leftSeriesName, rightSeriesName].sort((a, b) => {
|
||||
if (!legendSort) {
|
||||
return 0;
|
||||
}
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
});
|
||||
|
||||
const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
|
||||
chartHeight: height,
|
||||
chartWidth: width,
|
||||
legendItems: legendData,
|
||||
legendMargin,
|
||||
orientation: legendOrientation,
|
||||
show: showLegend,
|
||||
theme,
|
||||
type: legendType,
|
||||
});
|
||||
|
||||
const legendPadding = getChartPadding(
|
||||
showLegend,
|
||||
legendOrientation,
|
||||
effectiveLegendMargin,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const echartOptions: EChartsOption = {
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
top:
|
||||
theme.sizeUnit * 5 +
|
||||
legendPadding.top +
|
||||
convertInteger(xAxisTitleMargin),
|
||||
bottom: theme.sizeUnit * 5 + legendPadding.bottom,
|
||||
left:
|
||||
theme.sizeUnit * 5 +
|
||||
legendPadding.left +
|
||||
convertInteger(yAxisTitleMargin),
|
||||
right: theme.sizeUnit * 5 + legendPadding.right,
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(
|
||||
effectiveLegendType,
|
||||
legendOrientation,
|
||||
showLegend,
|
||||
theme,
|
||||
false,
|
||||
legendState,
|
||||
),
|
||||
data: legendData,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
position: 'top',
|
||||
name: xAxisLabel,
|
||||
nameLocation: 'middle',
|
||||
nameGap: convertInteger(xAxisTitleMargin),
|
||||
nameTextStyle: {
|
||||
padding: [theme.sizeUnit * 4, 0, 0, 0],
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: (value: number) => defaultFormatter(Math.abs(value)),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
name: yAxisLabel,
|
||||
nameLocation: 'middle',
|
||||
nameGap: convertInteger(yAxisTitleMargin),
|
||||
nameTextStyle: {
|
||||
padding: [0, theme.sizeUnit * 4, 0, 0],
|
||||
},
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: {
|
||||
rotate: xAxisLabelRotation,
|
||||
},
|
||||
data: categories,
|
||||
},
|
||||
tooltip: {
|
||||
...getDefaultTooltip(refs),
|
||||
appendToBody: true,
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
show: !inContextMenu,
|
||||
formatter: (params: CallbackDataParams | CallbackDataParams[]) =>
|
||||
formatTooltip(
|
||||
ensureIsArray(params) as CallbackDataParams[],
|
||||
defaultFormatter,
|
||||
),
|
||||
},
|
||||
series,
|
||||
};
|
||||
|
||||
return {
|
||||
refs,
|
||||
formData,
|
||||
width,
|
||||
height,
|
||||
echartOptions,
|
||||
setDataMask,
|
||||
onContextMenu,
|
||||
onLegendStateChanged,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 {
|
||||
ChartDataResponseResult,
|
||||
ChartProps,
|
||||
QueryFormColumn,
|
||||
QueryFormData,
|
||||
QueryFormMetric,
|
||||
RgbaColor,
|
||||
} from '@superset-ui/core';
|
||||
import { BaseTransformedProps, LegendFormData, TitleFormData } from '../types';
|
||||
|
||||
export type EchartsButterflyFormData = QueryFormData &
|
||||
LegendFormData &
|
||||
TitleFormData & {
|
||||
groupby: QueryFormColumn[];
|
||||
leftMetric: QueryFormMetric;
|
||||
rightMetric: QueryFormMetric;
|
||||
leftColor: RgbaColor;
|
||||
rightColor: RgbaColor;
|
||||
leftLabel?: string;
|
||||
rightLabel?: string;
|
||||
xAxisLabel: string;
|
||||
yAxisLabel: string;
|
||||
xAxisFormat: string;
|
||||
showValue: boolean;
|
||||
xAxisLabelRotation: number;
|
||||
};
|
||||
|
||||
export interface EchartsButterflyChartProps extends ChartProps {
|
||||
formData: EchartsButterflyFormData;
|
||||
queriesData: ChartDataResponseResult[];
|
||||
}
|
||||
|
||||
export type ButterflyTransformedProps =
|
||||
BaseTransformedProps<EchartsButterflyFormData>;
|
||||
@@ -38,6 +38,8 @@ import { EchartsTimeseriesSeriesType } from '../Timeseries/types';
|
||||
import {
|
||||
legendSection,
|
||||
minorTicks,
|
||||
axisTicks,
|
||||
gridlines,
|
||||
richTooltipSection,
|
||||
truncateXAxis,
|
||||
xAxisBounds,
|
||||
@@ -391,6 +393,8 @@ const config: ControlPanelConfig = {
|
||||
...createCustomizeSection(t('Query B'), 'B'),
|
||||
['zoomable'],
|
||||
[minorTicks],
|
||||
[axisTicks],
|
||||
[gridlines],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
['x_axis_time_format'],
|
||||
|
||||
+21
-26
@@ -73,8 +73,6 @@ import {
|
||||
getLegendProps,
|
||||
getMinAndMaxFromBounds,
|
||||
getOverMaxHiddenFormatter,
|
||||
getTemporalAxisTickConfig,
|
||||
getTemporalTickValues,
|
||||
} from '../utils/series';
|
||||
import { resolveLegendLayout } from '../utils/legendLayout';
|
||||
import {
|
||||
@@ -186,6 +184,8 @@ export default function transformProps(
|
||||
opacityB,
|
||||
minorSplitLine,
|
||||
minorTicks,
|
||||
gridlines,
|
||||
axisTicks,
|
||||
seriesType,
|
||||
seriesTypeB,
|
||||
showLegend,
|
||||
@@ -764,22 +764,6 @@ export default function transformProps(
|
||||
const { setDataMask = () => {}, onContextMenu } = hooks;
|
||||
const alignTicks = yAxisIndex !== yAxisIndexB;
|
||||
|
||||
// Weekly grains: pin the ticks to the buckets. Both queries share the axis.
|
||||
// Skipped when a timeseries annotation is shown: it widens the axis past the
|
||||
// buckets and ECharts clips pinned ticks to the extent, leaving that span bare.
|
||||
const hasTimeseriesAnnotation = annotationLayers.some(
|
||||
(layer: AnnotationLayer) =>
|
||||
layer.show && isTimeseriesAnnotationLayer(layer),
|
||||
);
|
||||
const temporalTickValues = hasTimeseriesAnnotation
|
||||
? undefined
|
||||
: getTemporalTickValues(
|
||||
[...rebasedDataA, ...rebasedDataB],
|
||||
xAxisLabel,
|
||||
xAxisType,
|
||||
resolvedTimeGrain,
|
||||
);
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
useUTC: true,
|
||||
grid: {
|
||||
@@ -791,15 +775,23 @@ export default function transformProps(
|
||||
name: xAxisTitle,
|
||||
nameGap: xAxisTitleMarginPx,
|
||||
nameLocation: 'middle',
|
||||
...getTemporalAxisTickConfig(
|
||||
temporalTickValues,
|
||||
showMaxLabel,
|
||||
xAxisType,
|
||||
xAxisLabelRotation,
|
||||
xAxisLabelInterval,
|
||||
deduplicatedFormatter,
|
||||
),
|
||||
axisLabel: {
|
||||
hideOverlap: showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
formatter: deduplicatedFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
...(showMaxLabel && {
|
||||
showMaxLabel: true,
|
||||
alignMaxLabel: 'right',
|
||||
showMinLabel: true,
|
||||
alignMinLabel: 'left',
|
||||
}),
|
||||
},
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
...(gridlines ? {} : { splitLine: { show: false } }),
|
||||
minInterval:
|
||||
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
|
||||
? (TIMEGRAIN_TO_TIMESTAMP[
|
||||
@@ -830,6 +822,8 @@ export default function transformProps(
|
||||
min: yAxisMin,
|
||||
max: yAxisMax,
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
splitLine: { show: gridlines },
|
||||
minorSplitLine: { show: minorSplitLine },
|
||||
axisLabel: {
|
||||
formatter: getYAxisFormatter(
|
||||
@@ -852,6 +846,7 @@ export default function transformProps(
|
||||
min: minSecondary,
|
||||
max: maxSecondary,
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
splitLine: { show: false },
|
||||
minorSplitLine: { show: minorSplitLine },
|
||||
axisLabel: {
|
||||
|
||||
@@ -48,6 +48,8 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
// shared properties
|
||||
minorSplitLine: boolean;
|
||||
minorTicks: boolean;
|
||||
gridlines: boolean;
|
||||
axisTicks: boolean;
|
||||
logAxis: boolean;
|
||||
logAxisSecondary: boolean;
|
||||
yAxisFormat?: string;
|
||||
@@ -113,6 +115,8 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
||||
...DEFAULT_LEGEND_FORM_DATA,
|
||||
annotationLayers: [],
|
||||
minorSplitLine: TIMESERIES_DEFAULTS.minorSplitLine,
|
||||
gridlines: TIMESERIES_DEFAULTS.gridlines,
|
||||
axisTicks: TIMESERIES_DEFAULTS.axisTicks,
|
||||
truncateYAxis: TIMESERIES_DEFAULTS.truncateYAxis,
|
||||
truncateYAxisSecondary: TIMESERIES_DEFAULTS.truncateYAxis,
|
||||
logAxis: TIMESERIES_DEFAULTS.logAxis,
|
||||
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
truncateXAxis,
|
||||
xAxisBounds,
|
||||
minorTicks,
|
||||
axisTicks,
|
||||
gridlines,
|
||||
forceMaxInterval,
|
||||
} from '../../controls';
|
||||
import { AreaChartStackControlOptions } from '../../constants';
|
||||
@@ -174,6 +176,8 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
],
|
||||
[minorTicks],
|
||||
[axisTicks],
|
||||
[gridlines],
|
||||
['zoomable'],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
|
||||
+2
@@ -133,6 +133,8 @@ const defaultFormData: EchartsTimeseriesFormData & {
|
||||
metrics: [],
|
||||
minorSplitLine: false,
|
||||
minorTicks: false,
|
||||
gridlines: true,
|
||||
axisTicks: true,
|
||||
opacity: 1,
|
||||
orderDesc: false,
|
||||
rowLimit: 0,
|
||||
|
||||
+4
@@ -40,6 +40,8 @@ import {
|
||||
import {
|
||||
legendSection,
|
||||
minorTicks,
|
||||
axisTicks,
|
||||
gridlines,
|
||||
richTooltipSection,
|
||||
seriesOrderSection,
|
||||
showValueSectionWithoutStream,
|
||||
@@ -388,6 +390,8 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
],
|
||||
[minorTicks],
|
||||
[axisTicks],
|
||||
[gridlines],
|
||||
['zoomable'],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
|
||||
+4
@@ -37,6 +37,8 @@ import {
|
||||
import {
|
||||
legendSection,
|
||||
minorTicks,
|
||||
axisTicks,
|
||||
gridlines,
|
||||
richTooltipSection,
|
||||
seriesOrderSection,
|
||||
showValueSection,
|
||||
@@ -156,6 +158,8 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
['zoomable'],
|
||||
[minorTicks],
|
||||
[axisTicks],
|
||||
[gridlines],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
+4
@@ -42,6 +42,8 @@ import {
|
||||
import {
|
||||
legendSection,
|
||||
minorTicks,
|
||||
axisTicks,
|
||||
gridlines,
|
||||
richTooltipSection,
|
||||
seriesOrderSection,
|
||||
showValueSection,
|
||||
@@ -480,6 +482,8 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
['zoomable'],
|
||||
[minorTicks],
|
||||
[axisTicks],
|
||||
[gridlines],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
...createAxisControl('x'),
|
||||
|
||||
+4
@@ -37,6 +37,8 @@ import {
|
||||
import {
|
||||
legendSection,
|
||||
minorTicks,
|
||||
axisTicks,
|
||||
gridlines,
|
||||
richTooltipSection,
|
||||
seriesOrderSection,
|
||||
showValueSectionWithoutStack,
|
||||
@@ -105,6 +107,8 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
['zoomable'],
|
||||
[minorTicks],
|
||||
[axisTicks],
|
||||
[gridlines],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
@@ -35,6 +35,8 @@ import { DEFAULT_FORM_DATA, TIME_SERIES_DESCRIPTION_TEXT } from '../constants';
|
||||
import {
|
||||
legendSection,
|
||||
minorTicks,
|
||||
axisTicks,
|
||||
gridlines,
|
||||
richTooltipSection,
|
||||
seriesOrderSection,
|
||||
showValueSection,
|
||||
@@ -157,6 +159,8 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
['zoomable'],
|
||||
[minorTicks],
|
||||
[axisTicks],
|
||||
[gridlines],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
@@ -67,6 +67,8 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
maxMarkerSize: 30,
|
||||
minMarkerSize: 5,
|
||||
minorSplitLine: false,
|
||||
gridlines: true,
|
||||
axisTicks: true,
|
||||
opacity: 0.2,
|
||||
orderDesc: true,
|
||||
rowLimit: 10000,
|
||||
|
||||
@@ -88,8 +88,6 @@ import {
|
||||
getHorizontalLegendAvailableWidth,
|
||||
getLegendProps,
|
||||
getMinAndMaxFromBounds,
|
||||
getTemporalAxisTickConfig,
|
||||
getTemporalTickValues,
|
||||
} from '../utils/series';
|
||||
import { resolveLegendLayout } from '../utils/legendLayout';
|
||||
import {
|
||||
@@ -285,6 +283,8 @@ export default function transformProps(
|
||||
metrics,
|
||||
minorSplitLine,
|
||||
minorTicks,
|
||||
gridlines,
|
||||
axisTicks,
|
||||
onlyTotal,
|
||||
opacity,
|
||||
orientation,
|
||||
@@ -1247,23 +1247,6 @@ export default function transformProps(
|
||||
})()
|
||||
: xAxisFormatter;
|
||||
|
||||
// Weekly grains: pin the ticks to the buckets ECharts would otherwise miss.
|
||||
// A timeseries annotation contributes its own timestamps and widens the axis
|
||||
// past the buckets, and ECharts clips pinned ticks to the extent, so that
|
||||
// span would render bare — leave those charts on ECharts' own ticks.
|
||||
const hasTimeseriesAnnotation = annotationLayers.some(
|
||||
(layer: AnnotationLayer) =>
|
||||
layer.show && isTimeseriesAnnotationLayer(layer),
|
||||
);
|
||||
const temporalTickValues = hasTimeseriesAnnotation
|
||||
? undefined
|
||||
: getTemporalTickValues(
|
||||
rebasedData,
|
||||
xAxisLabel,
|
||||
xAxisType,
|
||||
resolvedTimeGrain,
|
||||
);
|
||||
|
||||
let xAxis: any = {
|
||||
type: xAxisType,
|
||||
name: xAxisTitle,
|
||||
@@ -1273,15 +1256,39 @@ export default function transformProps(
|
||||
groupBy.length === 0 && {
|
||||
triggerEvent: true,
|
||||
}),
|
||||
...getTemporalAxisTickConfig(
|
||||
temporalTickValues,
|
||||
showMaxLabel,
|
||||
xAxisType,
|
||||
xAxisLabelRotation,
|
||||
xAxisLabelInterval,
|
||||
deduplicatedFormatter,
|
||||
),
|
||||
axisLabel: {
|
||||
// When rotation is applied on time axes, hideOverlap can
|
||||
// aggressively hide the last label. Rotated labels already
|
||||
// have less overlap, so disabling hideOverlap is safe.
|
||||
// At 0° rotation, also disable hideOverlap when showMaxLabel
|
||||
// is active so the forced boundary label is never suppressed
|
||||
// by ECharts' overlap detection (#39899).
|
||||
hideOverlap: showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
formatter: deduplicatedFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
// Force the boundary labels on non-rotated time axes so the first
|
||||
// and last dates stay visible: hideOverlap can hide the last label,
|
||||
// and a min date that falls between "nice" ticks otherwise renders
|
||||
// no beginning label. Skipped when rotated to avoid phantom labels
|
||||
// at the axis boundary.
|
||||
...(showMaxLabel && {
|
||||
showMaxLabel: true,
|
||||
showMinLabel: true,
|
||||
}),
|
||||
// The alignments assume the axis runs along the bottom; a horizontal
|
||||
// chart puts this axis on the side, where they misplace the labels.
|
||||
...(showMaxLabel &&
|
||||
!isHorizontal && {
|
||||
alignMaxLabel: 'right',
|
||||
alignMinLabel: 'left',
|
||||
}),
|
||||
},
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
...(gridlines ? {} : { splitLine: { show: false } }),
|
||||
minInterval:
|
||||
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
|
||||
? (TIMEGRAIN_TO_TIMESTAMP[
|
||||
@@ -1326,7 +1333,7 @@ export default function transformProps(
|
||||
max: yAxisMax,
|
||||
minorTick: { show: isSmallChart ? false : minorTicks },
|
||||
minorSplitLine: { show: isSmallChart ? false : minorSplitLine },
|
||||
splitLine: { show: !isSmallChart },
|
||||
splitLine: { show: isSmallChart ? false : gridlines },
|
||||
axisLabel: {
|
||||
show: !isMicroChart,
|
||||
showMinLabel: !isMicroChart,
|
||||
@@ -1340,7 +1347,7 @@ export default function transformProps(
|
||||
yAxisFormat,
|
||||
),
|
||||
},
|
||||
axisTick: { show: !isSmallChart },
|
||||
axisTick: { show: isSmallChart ? false : axisTicks },
|
||||
scale: truncateYAxis,
|
||||
name: isSmallChart ? undefined : yAxisTitle,
|
||||
nameGap: convertInteger(yAxisTitleMargin),
|
||||
|
||||
@@ -73,6 +73,8 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
metrics: QueryFormMetric[];
|
||||
minorSplitLine: boolean;
|
||||
minorTicks: boolean;
|
||||
gridlines: boolean;
|
||||
axisTicks: boolean;
|
||||
opacity: number;
|
||||
orderDesc: boolean;
|
||||
rowLimit: number;
|
||||
|
||||
@@ -89,16 +89,6 @@ export const StackControlOptionsWithoutStream: [
|
||||
[StackControlsValue.Stack, t('Stack')],
|
||||
];
|
||||
|
||||
// Grains ECharts' time axis cannot tick on; see getTemporalTickValues in
|
||||
// utils/series.
|
||||
export const WEEKLY_TIME_GRAINS: ReadonlySet<string> = new Set([
|
||||
TimeGranularity.WEEK,
|
||||
TimeGranularity.WEEK_STARTING_SUNDAY,
|
||||
TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
TimeGranularity.WEEK_ENDING_SATURDAY,
|
||||
TimeGranularity.WEEK_ENDING_SUNDAY,
|
||||
]);
|
||||
|
||||
export const TIMEGRAIN_TO_TIMESTAMP = {
|
||||
[TimeGranularity.HOUR]: 3600 * 1000,
|
||||
[TimeGranularity.DAY]: 3600 * 1000 * 24,
|
||||
|
||||
@@ -495,6 +495,28 @@ export const minorTicks: ControlSetItem = {
|
||||
},
|
||||
};
|
||||
|
||||
export const axisTicks: ControlSetItem = {
|
||||
name: 'axisTicks',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Axis ticks'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
description: t('Show the main ticks on axes.'),
|
||||
},
|
||||
};
|
||||
|
||||
export const gridlines: ControlSetItem = {
|
||||
name: 'gridlines',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Gridlines'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
description: t('Draw split lines for the main value axis ticks.'),
|
||||
},
|
||||
};
|
||||
|
||||
export const forceCategorical: ControlSetItem = {
|
||||
name: 'forceCategorical',
|
||||
config: {
|
||||
|
||||
@@ -46,6 +46,7 @@ export { default as EchartsSunburstChartPlugin } from './Sunburst';
|
||||
export { default as EchartsBubbleChartPlugin } from './Bubble';
|
||||
export { default as EchartsSankeyChartPlugin } from './Sankey';
|
||||
export { default as EchartsWaterfallChartPlugin } from './Waterfall';
|
||||
export { default as EchartsButterflyChartPlugin } from './Butterfly';
|
||||
export { default as EchartsGanttChartPlugin } from './Gantt';
|
||||
|
||||
export { default as BoxPlotTransformProps } from './BoxPlot/transformProps';
|
||||
@@ -62,6 +63,7 @@ export { default as HeatmapTransformProps } from './Heatmap/transformProps';
|
||||
export { default as SunburstTransformProps } from './Sunburst/transformProps';
|
||||
export { default as BubbleTransformProps } from './Bubble/transformProps';
|
||||
export { default as WaterfallTransformProps } from './Waterfall/transformProps';
|
||||
export { default as ButterflyTransformProps } from './Butterfly/transformProps';
|
||||
export { default as HistogramTransformProps } from './Histogram/transformProps';
|
||||
export { default as SankeyTransformProps } from './Sankey/transformProps';
|
||||
export { default as GanttTransformProps } from './Gantt/transformProps';
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
NULL_STRING,
|
||||
StackControlsValue,
|
||||
TIMESERIES_CONSTANTS,
|
||||
WEEKLY_TIME_GRAINS,
|
||||
} from '../constants';
|
||||
import {
|
||||
EchartsTimeseriesSeriesType,
|
||||
@@ -987,134 +986,6 @@ export function getAxisType(
|
||||
return AxisType.Category;
|
||||
}
|
||||
|
||||
// `new Date('2024-04-06')` parses as UTC, but ECharts' own date parser treats
|
||||
// zone-less strings as local time — mismatch would offset the pinned tick.
|
||||
const DATE_ONLY_RE = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/;
|
||||
|
||||
function parseTemporalString(value: string): number {
|
||||
const dateOnly = DATE_ONLY_RE.exec(value);
|
||||
if (dateOnly) {
|
||||
const [, year, month, day] = dateOnly;
|
||||
return new Date(
|
||||
Number(year),
|
||||
Number(month || 1) - 1,
|
||||
Number(day || 1),
|
||||
).getTime();
|
||||
}
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket timestamps a temporal axis should tick on, or undefined to let ECharts
|
||||
* choose.
|
||||
*
|
||||
* ECharts generates time ticks from a calendar ladder with no week unit, so for
|
||||
* weekly data it steps days from the 1st of each month instead: labels drift
|
||||
* across weekdays and snap to month starts (#17226). Coarser grains already land
|
||||
* on their data and keep ECharts' calendar-nice labels.
|
||||
*/
|
||||
export function getTemporalTickValues(
|
||||
data: DataRecord[],
|
||||
xAxisLabel: string,
|
||||
xAxisType: AxisType,
|
||||
timeGrain?: string,
|
||||
): number[] | undefined {
|
||||
if (
|
||||
xAxisType !== AxisType.Time ||
|
||||
!timeGrain ||
|
||||
!WEEKLY_TIME_GRAINS.has(timeGrain)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const values = new Set<number>();
|
||||
data.forEach(row => {
|
||||
const value = row[xAxisLabel];
|
||||
const timestamp =
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
value instanceof Date
|
||||
? value.getTime()
|
||||
: typeof value === 'string'
|
||||
? parseTemporalString(value)
|
||||
: Number(value ?? NaN);
|
||||
if (Number.isFinite(timestamp)) {
|
||||
values.add(timestamp);
|
||||
}
|
||||
});
|
||||
return values.size ? [...values].sort((a, b) => a - b) : undefined;
|
||||
}
|
||||
|
||||
// Unlike axisLabel, axisTick has no overlap-based thinning, so pinning it to
|
||||
// every bucket combs a long weekly range. Downsample evenly, keeping ends.
|
||||
const MAX_PINNED_AXIS_TICKS = 60;
|
||||
|
||||
export function capTickMarks(
|
||||
values: number[],
|
||||
maxTicks: number = MAX_PINNED_AXIS_TICKS,
|
||||
): number[] {
|
||||
if (values.length <= maxTicks) {
|
||||
return values;
|
||||
}
|
||||
const step = Math.ceil(values.length / maxTicks);
|
||||
const capped = values.filter((_, index) => index % step === 0);
|
||||
const last = values[values.length - 1];
|
||||
if (capped[capped.length - 1] !== last) {
|
||||
capped.push(last);
|
||||
}
|
||||
return capped;
|
||||
}
|
||||
|
||||
/**
|
||||
* axisLabel/axisTick fragment for a temporal x-axis, shared by Timeseries and
|
||||
* MixedTimeseries. When temporalTickValues pins the axis to weekly buckets,
|
||||
* both axisLabel.customValues (what hideOverlap thins from) and
|
||||
* axisTick.customValues (what splitLine/gridlines follow) use the same capped
|
||||
* set, so a label that survives hideOverlap thinning always lands on a real
|
||||
* tick and gridline rather than a capped-away bucket.
|
||||
*/
|
||||
export function getTemporalAxisTickConfig(
|
||||
temporalTickValues: number[] | undefined,
|
||||
showMaxLabel: boolean,
|
||||
xAxisType: AxisType,
|
||||
xAxisLabelRotation: number,
|
||||
xAxisLabelInterval: number | string | undefined,
|
||||
formatter: unknown,
|
||||
): {
|
||||
axisLabel: Record<string, unknown>;
|
||||
axisTick?: { customValues: number[] };
|
||||
} {
|
||||
const cappedTickValues = temporalTickValues
|
||||
? capTickMarks(temporalTickValues)
|
||||
: undefined;
|
||||
return {
|
||||
axisLabel: {
|
||||
// Pinned ticks label every bucket, which does crowd, so thinning
|
||||
// always wins there.
|
||||
hideOverlap:
|
||||
!!temporalTickValues ||
|
||||
(showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)),
|
||||
formatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
// Force the boundary labels so the first and last dates stay visible:
|
||||
// hideOverlap can hide the last label, and a min date that falls
|
||||
// between "nice" ticks otherwise renders no beginning label. Applied
|
||||
// for pinned axes too — showMaxLabel only shields its immediate
|
||||
// neighbour, so a farther label on a crowded weekly axis can still be
|
||||
// dropped, but that's strictly better than no shielding at all.
|
||||
...(showMaxLabel && {
|
||||
showMaxLabel: true,
|
||||
alignMaxLabel: 'right',
|
||||
showMinLabel: true,
|
||||
alignMinLabel: 'left',
|
||||
}),
|
||||
...(cappedTickValues && { customValues: cappedTickValues }),
|
||||
},
|
||||
...(cappedTickValues && { axisTick: { customValues: cappedTickValues } }),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOverMaxHiddenFormatter(
|
||||
config: {
|
||||
max?: number;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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 buildQuery from '../../src/Butterfly/buildQuery';
|
||||
|
||||
const formData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'butterfly',
|
||||
groupby: ['category'],
|
||||
left_metric: 'left_sum',
|
||||
right_metric: 'right_sum',
|
||||
};
|
||||
|
||||
test('defaults to ordering by the category column', () => {
|
||||
const [query] = buildQuery(formData).queries;
|
||||
expect(query.columns).toEqual(['category']);
|
||||
expect(query.metrics).toEqual(['left_sum', 'right_sum']);
|
||||
expect(query.orderby).toEqual([['category', true]]);
|
||||
});
|
||||
|
||||
test('wraps the sort metric in a valid orderby tuple', () => {
|
||||
const sortMetric = {
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: 'left_sum' },
|
||||
aggregate: 'SUM',
|
||||
label: 'SUM(left_sum)',
|
||||
};
|
||||
const [query] = buildQuery({
|
||||
...formData,
|
||||
orderby: sortMetric,
|
||||
order_desc: true,
|
||||
}).queries;
|
||||
expect(query.metrics).toEqual(['left_sum', 'right_sum', sortMetric]);
|
||||
expect(query.orderby).toEqual([[sortMetric, false]]);
|
||||
});
|
||||
|
||||
test('appends the sort metric when it is not already selected', () => {
|
||||
const sortMetric = {
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: 'count' },
|
||||
aggregate: 'SUM',
|
||||
label: 'SUM(count)',
|
||||
};
|
||||
const [query] = buildQuery({
|
||||
...formData,
|
||||
orderby: sortMetric,
|
||||
order_desc: false,
|
||||
}).queries;
|
||||
expect(query.metrics).toEqual(['left_sum', 'right_sum', sortMetric]);
|
||||
expect(query.orderby).toEqual([[sortMetric, true]]);
|
||||
});
|
||||
|
||||
test('leaves orderby unset when no category column is selected', () => {
|
||||
const [query] = buildQuery({
|
||||
...formData,
|
||||
groupby: [],
|
||||
}).queries;
|
||||
expect(query.columns).toEqual([]);
|
||||
expect(query.metrics).toEqual(['left_sum', 'right_sum']);
|
||||
expect(query.orderby).toBeUndefined();
|
||||
});
|
||||
|
||||
test('issues no metrics when none are selected', () => {
|
||||
const [query] = buildQuery({
|
||||
datasource: '1__table',
|
||||
viz_type: 'butterfly',
|
||||
groupby: ['category'],
|
||||
}).queries;
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SqlaFormData } from '@superset-ui/core';
|
||||
|
||||
const mockShiftMetric = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('left_sum')
|
||||
.mockReturnValueOnce('right_sum');
|
||||
const mockShiftColumn = jest.fn(() => 'category');
|
||||
|
||||
jest.mock('@superset-ui/chart-controls', () => {
|
||||
const actual = jest.requireActual('@superset-ui/chart-controls');
|
||||
return {
|
||||
...actual,
|
||||
getStandardizedControls: jest.fn(() => ({
|
||||
shiftMetric: mockShiftMetric,
|
||||
shiftColumn: mockShiftColumn,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import controlPanel from '../../src/Butterfly/controlPanel';
|
||||
|
||||
const collectControlNames = () => {
|
||||
const names = new Set<string>();
|
||||
controlPanel.controlPanelSections?.forEach(section => {
|
||||
section?.controlSetRows?.forEach(row => {
|
||||
row.forEach(control => {
|
||||
if (typeof control === 'string') {
|
||||
names.add(control);
|
||||
} else if (
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control
|
||||
) {
|
||||
names.add(String(control.name));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return names;
|
||||
};
|
||||
|
||||
test('exposes left and right metric controls', () => {
|
||||
const controlNames = collectControlNames();
|
||||
expect(controlNames.has('left_metric')).toBe(true);
|
||||
expect(controlNames.has('right_metric')).toBe(true);
|
||||
expect(controlNames.has('groupby')).toBe(true);
|
||||
expect(controlNames.has('orderby')).toBe(true);
|
||||
});
|
||||
|
||||
test('restricts categories to a single dimension', () => {
|
||||
expect(controlPanel.controlOverrides?.groupby?.multi).toBe(false);
|
||||
});
|
||||
|
||||
test('maps standardized controls to butterfly metrics', () => {
|
||||
const dummyFormData = { someProp: 'test' } as unknown as SqlaFormData;
|
||||
const formData = controlPanel.formDataOverrides?.(dummyFormData);
|
||||
|
||||
expect(formData?.someProp).toBe('test');
|
||||
expect(formData?.groupby).toEqual(['category']);
|
||||
expect(formData?.left_metric).toBe('left_sum');
|
||||
expect(formData?.right_metric).toBe('right_sum');
|
||||
expect(mockShiftMetric).toHaveBeenCalledTimes(2);
|
||||
expect(mockShiftColumn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
EchartsButterflyChartProps,
|
||||
ButterflyTransformedProps,
|
||||
} from '../../src/Butterfly/types';
|
||||
import transformProps from '../../src/Butterfly/transformProps';
|
||||
import { NULL_STRING } from '../../src/constants';
|
||||
|
||||
type SeriesDataPoint = { value?: number } | number;
|
||||
|
||||
type ButterflyTestSeries = {
|
||||
name?: string;
|
||||
data?: SeriesDataPoint[];
|
||||
itemStyle?: { color?: string };
|
||||
label?: { show?: boolean };
|
||||
};
|
||||
|
||||
type ButterflyTestEchartOptions = {
|
||||
series?: ButterflyTestSeries[];
|
||||
xAxis?: { name?: string; nameGap?: number };
|
||||
yAxis?: {
|
||||
name?: string;
|
||||
nameGap?: number;
|
||||
data?: string[];
|
||||
axisLabel?: { rotate?: number };
|
||||
};
|
||||
legend?: { orient?: string; data?: string[] };
|
||||
grid?: { left?: number; top?: number };
|
||||
tooltip?: { show?: boolean };
|
||||
};
|
||||
|
||||
const getEchartOptions = (
|
||||
props: ButterflyTransformedProps,
|
||||
): ButterflyTestEchartOptions =>
|
||||
props.echartOptions as ButterflyTestEchartOptions;
|
||||
|
||||
const extractSeriesValues = (props: ButterflyTransformedProps) => {
|
||||
const series = getEchartOptions(props).series ?? [];
|
||||
return series.map(item =>
|
||||
(item.data ?? []).map(entry =>
|
||||
typeof entry === 'object' && entry !== null && 'value' in entry
|
||||
? entry.value
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const extractSeriesNames = (props: ButterflyTransformedProps) => {
|
||||
const series = getEchartOptions(props).series ?? [];
|
||||
return series.map(item => item.name);
|
||||
};
|
||||
|
||||
const data: Record<string, unknown>[] = [
|
||||
{ category: 'A', left_sum: 10, right_sum: 25 },
|
||||
{ category: 'B', left_sum: 5, right_sum: 19 },
|
||||
];
|
||||
|
||||
const formData = {
|
||||
groupby: ['category'],
|
||||
left_metric: 'left_sum',
|
||||
right_metric: 'right_sum',
|
||||
left_color: { r: 84, g: 112, b: 198 },
|
||||
right_color: { r: 145, g: 204, b: 117 },
|
||||
showValue: true,
|
||||
showLegend: true,
|
||||
};
|
||||
|
||||
const createChartProps = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
queryData: Record<string, unknown>[] = data,
|
||||
) =>
|
||||
new ChartProps({
|
||||
formData: { ...formData, ...overrides },
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [{ data: queryData }],
|
||||
theme: supersetTheme,
|
||||
...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
|
||||
});
|
||||
|
||||
test('transforms chart props into diverging bar series', () => {
|
||||
const transformedProps = transformProps(
|
||||
createChartProps() as unknown as EchartsButterflyChartProps,
|
||||
);
|
||||
|
||||
expect(extractSeriesValues(transformedProps)).toEqual([
|
||||
[-10, -5],
|
||||
[25, 19],
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses absolute values for negative right-side metrics', () => {
|
||||
const transformedProps = transformProps(
|
||||
createChartProps({}, [
|
||||
{ category: 'A', left_sum: -8, right_sum: -15 },
|
||||
]) as unknown as EchartsButterflyChartProps,
|
||||
);
|
||||
|
||||
expect(extractSeriesValues(transformedProps)).toEqual([[-8], [15]]);
|
||||
});
|
||||
|
||||
test('formats null categories and missing metric values', () => {
|
||||
const transformedProps = transformProps(
|
||||
createChartProps({}, [
|
||||
{ category: null, left_sum: undefined, right_sum: 7 },
|
||||
]) as unknown as EchartsButterflyChartProps,
|
||||
);
|
||||
const { yAxis } = getEchartOptions(transformedProps);
|
||||
|
||||
expect(yAxis?.data).toEqual([NULL_STRING]);
|
||||
const [leftValues, rightValues] = extractSeriesValues(transformedProps);
|
||||
expect(Math.abs(leftValues[0] as number)).toBe(0);
|
||||
expect(rightValues).toEqual([7]);
|
||||
});
|
||||
|
||||
test('applies custom series labels, colors, and axis titles', () => {
|
||||
const transformedProps = transformProps(
|
||||
createChartProps({
|
||||
left_label: 'Left side',
|
||||
right_label: 'Right side',
|
||||
left_color: { r: 255, g: 0, b: 0 },
|
||||
right_color: { r: 0, g: 255, b: 0 },
|
||||
x_axis_label: 'Value axis',
|
||||
y_axis_label: 'Category axis',
|
||||
}) as unknown as EchartsButterflyChartProps,
|
||||
);
|
||||
const { series, xAxis, yAxis } = getEchartOptions(transformedProps);
|
||||
|
||||
expect(extractSeriesNames(transformedProps)).toEqual([
|
||||
'Left side',
|
||||
'Right side',
|
||||
]);
|
||||
expect(series?.[0]?.itemStyle?.color).toBe('#ff0000');
|
||||
expect(series?.[1]?.itemStyle?.color).toBe('#00ff00');
|
||||
expect(xAxis?.name).toBe('Value axis');
|
||||
expect(yAxis?.name).toBe('Category axis');
|
||||
});
|
||||
|
||||
test('applies legend orientation, sort, and axis margin settings', () => {
|
||||
const transformedProps = transformProps(
|
||||
createChartProps({
|
||||
legendOrientation: 'left',
|
||||
legendSort: 'desc',
|
||||
xAxisLabelRotation: 45,
|
||||
x_axis_title_margin: 60,
|
||||
y_axis_title_margin: 80,
|
||||
}) as unknown as EchartsButterflyChartProps,
|
||||
);
|
||||
const { legend, xAxis, yAxis, grid } = getEchartOptions(transformedProps);
|
||||
|
||||
expect(legend?.orient).toBe('vertical');
|
||||
expect(legend?.data).toEqual(['right_sum', 'left_sum']);
|
||||
expect(xAxis?.nameGap).toBe(60);
|
||||
expect(yAxis?.axisLabel?.rotate).toBe(45);
|
||||
expect(yAxis?.nameGap).toBe(80);
|
||||
expect(grid?.left).toBeGreaterThan(80);
|
||||
expect(grid?.top).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
test('hides value labels when showValue is false', () => {
|
||||
const transformedProps = transformProps(
|
||||
createChartProps({
|
||||
showValue: false,
|
||||
}) as unknown as EchartsButterflyChartProps,
|
||||
);
|
||||
const { series } = getEchartOptions(transformedProps);
|
||||
|
||||
expect(series?.[0]?.label?.show).toBe(false);
|
||||
expect(series?.[1]?.label?.show).toBe(false);
|
||||
});
|
||||
|
||||
test('hides tooltip while the context menu is open', () => {
|
||||
const transformedProps = transformProps(
|
||||
createChartProps({}, data) as unknown as EchartsButterflyChartProps,
|
||||
);
|
||||
const withContextMenu = transformProps({
|
||||
...createChartProps(),
|
||||
inContextMenu: true,
|
||||
} as unknown as EchartsButterflyChartProps);
|
||||
|
||||
expect(getEchartOptions(transformedProps).tooltip?.show).toBe(true);
|
||||
expect(getEchartOptions(withContextMenu).tooltip?.show).toBe(false);
|
||||
});
|
||||
+44
-81
@@ -116,6 +116,8 @@ const formData: EchartsMixedTimeseriesFormData = {
|
||||
markerSizeB: 0,
|
||||
minorSplitLine: false,
|
||||
minorTicks: false,
|
||||
gridlines: true,
|
||||
axisTicks: true,
|
||||
opacity: 0,
|
||||
opacityB: 0,
|
||||
orderDesc: false,
|
||||
@@ -1510,94 +1512,55 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly x-axis tick alignment', () => {
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const MONDAYS = Array.from(
|
||||
{ length: 6 },
|
||||
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
|
||||
);
|
||||
const weeklyLabelMap = { ds: ['ds'], sum__num: ['sum__num'] };
|
||||
|
||||
const weeklyQuery = (timestamps: number[]) =>
|
||||
createTestQueryData(
|
||||
timestamps.map((ds, i) => ({ ds, sum__num: 10 + i })),
|
||||
{
|
||||
label_map: weeklyLabelMap,
|
||||
colnames: ['ds', 'sum__num'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
},
|
||||
);
|
||||
|
||||
const weeklyChartProps = (
|
||||
queryA: number[],
|
||||
queryB: number[],
|
||||
overrides: Partial<EchartsMixedTimeseriesFormData> = {},
|
||||
) =>
|
||||
createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
groupbyB: [],
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
...overrides,
|
||||
},
|
||||
queriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
|
||||
});
|
||||
|
||||
test('pins ticks, labels and gridlines to the weekly buckets', () => {
|
||||
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
|
||||
.echartOptions as any;
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Time);
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
|
||||
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.splitLine).toBeUndefined();
|
||||
function transformWithChrome(
|
||||
overrides: Partial<EchartsMixedTimeseriesFormData>,
|
||||
) {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, ...overrides },
|
||||
queriesData,
|
||||
});
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
return {
|
||||
xAxis: echartOptions.xAxis as any,
|
||||
yAxis: echartOptions.yAxis as any[],
|
||||
};
|
||||
}
|
||||
|
||||
test('keeps label thinning on when the labels are rotated', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(MONDAYS, MONDAYS, { xAxisLabelRotation: 45 }),
|
||||
).echartOptions as any;
|
||||
test('draws gridlines and axis ticks when both are enabled', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({});
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
expect(yAxis[0].splitLine.show).toBe(true);
|
||||
// Both axes keep ECharts' own default, which the Mixed chart never overrode.
|
||||
expect(yAxis[0].axisTick.show).toBe('auto');
|
||||
expect(xAxis.axisTick.show).toBe('auto');
|
||||
});
|
||||
|
||||
test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => {
|
||||
// hideOverlap stays on for pinned ticks (they label every bucket), but
|
||||
// showMaxLabel still shields the boundary label's immediate neighbour
|
||||
// so the last bucket isn't silently dropped (#39899).
|
||||
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
|
||||
.echartOptions as any;
|
||||
test('hides the gridlines on the primary axis', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({ gridlines: false });
|
||||
|
||||
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
expect(yAxis[0].splitLine.show).toBe(false);
|
||||
// The secondary axis never draws gridlines, so the two grids cannot double up.
|
||||
expect(yAxis[1].splitLine.show).toBe(false);
|
||||
expect(xAxis.splitLine.show).toBe(false);
|
||||
});
|
||||
|
||||
test('covers buckets contributed by either query', () => {
|
||||
// The two queries share one axis, so a bucket present in only one of them
|
||||
// still needs a tick.
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(MONDAYS.slice(0, 3), MONDAYS.slice(2)),
|
||||
).echartOptions as any;
|
||||
test('never turns the secondary axis gridlines on', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({ gridlines: true });
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
expect(yAxis[0].splitLine.show).toBe(true);
|
||||
expect(yAxis[1].splitLine.show).toBe(false);
|
||||
expect(xAxis.splitLine).toBeUndefined();
|
||||
});
|
||||
|
||||
test('leaves grains ECharts places correctly untouched', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(MONDAYS, MONDAYS, {
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}),
|
||||
).echartOptions as any;
|
||||
test('hides the ticks on the x axis and both y axes', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({ axisTicks: false });
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisTick?.customValues).toBeUndefined();
|
||||
});
|
||||
expect(xAxis.axisTick.show).toBe(false);
|
||||
expect(yAxis[0].axisTick.show).toBe(false);
|
||||
expect(yAxis[1].axisTick.show).toBe(false);
|
||||
});
|
||||
|
||||
+116
-270
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
AnnotationData,
|
||||
AnnotationSourceType,
|
||||
AnnotationStyle,
|
||||
AnnotationType,
|
||||
@@ -2707,275 +2706,6 @@ describe('EchartsTimeseries tooltip truncation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly x-axis tick alignment', () => {
|
||||
// 13 Monday-aligned weekly buckets, the shape produced by a dataset that is
|
||||
// pre-aggregated to weeks.
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const MONDAYS = Array.from(
|
||||
{ length: 13 },
|
||||
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
|
||||
);
|
||||
|
||||
const weeklyChartProps = (
|
||||
formDataOverrides: Partial<EchartsTimeseriesFormData> = {},
|
||||
annotationData?: AnnotationData,
|
||||
) =>
|
||||
createTestChartProps({
|
||||
annotationData,
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
xAxisTimeFormat: '%m-%d',
|
||||
...formDataOverrides,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
MONDAYS.map((__timestamp, i) => ({ __timestamp, sales: 100 + i })),
|
||||
{
|
||||
colnames: ['__timestamp', 'sales'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
// transformProps reads annotations off the query, not chartProps.
|
||||
...(annotationData && { annotation_data: annotationData }),
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
test('pins ticks, labels and gridlines to the weekly buckets', () => {
|
||||
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Time);
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
|
||||
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.splitLine).toBeUndefined();
|
||||
});
|
||||
|
||||
test('caps axisLabel.customValues to the same subset as axisTick, not the full bucket set', () => {
|
||||
// hideOverlap thins whichever set axisLabel.customValues offers it. If
|
||||
// that set were the full (uncapped) bucket list while axisTick/splitLine
|
||||
// only kept a downsampled subset, a surviving label could land on a
|
||||
// bucket with no tick or gridline under it.
|
||||
const manyMondays = Array.from(
|
||||
{ length: 261 },
|
||||
(_, i) => Date.UTC(2021, 0, 4) + i * WEEK_MS,
|
||||
);
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
xAxisTimeFormat: '%m-%d',
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
manyMondays.map((__timestamp, i) => ({
|
||||
__timestamp,
|
||||
sales: 100 + i,
|
||||
})),
|
||||
{
|
||||
colnames: ['__timestamp', 'sales'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
const { xAxis } = transformProps(chartProps).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisTick.customValues.length).toBeLessThan(manyMondays.length);
|
||||
expect(xAxis.axisLabel.customValues).toEqual(xAxis.axisTick.customValues);
|
||||
});
|
||||
|
||||
test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => {
|
||||
// hideOverlap stays on for pinned ticks (they label every bucket), but
|
||||
// showMaxLabel still shields the boundary label's immediate neighbour
|
||||
// so the last bucket isn't silently dropped (#39899).
|
||||
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
|
||||
test('pins ticks when the bucket column holds ISO date strings', () => {
|
||||
// A dataset can arrive with __timestamp serialized as an ISO string
|
||||
// rather than a Date/epoch-ms value.
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
MONDAYS.map((__timestamp, i) => ({
|
||||
__timestamp: new Date(__timestamp).toISOString(),
|
||||
sales: 100 + i,
|
||||
})),
|
||||
{
|
||||
colnames: ['__timestamp', 'sales'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
const { xAxis } = transformProps(chartProps).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test('keeps label thinning on when the labels are rotated', () => {
|
||||
// Rotation normally turns hideOverlap off, but pinned ticks put a label on
|
||||
// every bucket, so without thinning a multi-year range draws hundreds.
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({ xAxisLabelRotation: 45 }),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
|
||||
test('leaves rotation thinning alone when the ticks are not pinned', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
xAxisLabelRotation: 45,
|
||||
}),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(false);
|
||||
});
|
||||
|
||||
const timeseriesLayer = (show: boolean) =>
|
||||
({
|
||||
name: 'my annotation',
|
||||
annotationType: AnnotationType.Timeseries,
|
||||
sourceType: AnnotationSourceType.Line,
|
||||
style: AnnotationStyle.Solid,
|
||||
show,
|
||||
value: 1,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
|
||||
// The annotation's own timestamps run a year past the last bucket.
|
||||
const annotationRecords = {
|
||||
'my annotation': {
|
||||
records: [
|
||||
{ ds: MONDAYS[0], y: 1 },
|
||||
{ ds: MONDAYS[12] + 52 * WEEK_MS, y: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test('does not pin ticks when a timeseries annotation widens the axis', () => {
|
||||
// A Time axis takes no min/max, so it stretches to cover the annotation
|
||||
// while ECharts clips pinned ticks to the extent — that span would be bare.
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(
|
||||
{ annotationLayers: [timeseriesLayer(true)] },
|
||||
annotationRecords,
|
||||
),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisTick?.customValues).toBeUndefined();
|
||||
});
|
||||
|
||||
test('still pins ticks for a hidden timeseries annotation', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(
|
||||
{ annotationLayers: [timeseriesLayer(false)] },
|
||||
annotationRecords,
|
||||
),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test.each([
|
||||
TimeGranularity.WEEK,
|
||||
TimeGranularity.WEEK_STARTING_SUNDAY,
|
||||
TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
TimeGranularity.WEEK_ENDING_SATURDAY,
|
||||
TimeGranularity.WEEK_ENDING_SUNDAY,
|
||||
])('applies to the %s grain', grain => {
|
||||
const { xAxis } = transformProps(weeklyChartProps({ timeGrainSqla: grain }))
|
||||
.echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test('a dashboard time-grain override drives the alignment', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
extraFormData: { time_grain_sqla: TimeGranularity.WEEK },
|
||||
}),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test('deduplicates and sorts the bucket timestamps', () => {
|
||||
// A grouped query repeats each bucket once per series, and the rows are
|
||||
// not necessarily ordered.
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK,
|
||||
groupby: ['region'],
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
[
|
||||
{ __timestamp: MONDAYS[1], region: 'b', sales: 2 },
|
||||
{ __timestamp: MONDAYS[0], region: 'a', sales: 1 },
|
||||
{ __timestamp: MONDAYS[1], region: 'a', sales: 3 },
|
||||
{ __timestamp: MONDAYS[0], region: 'b', sales: 4 },
|
||||
],
|
||||
{
|
||||
colnames: ['__timestamp', 'region', 'sales'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.String,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
const { xAxis } = transformProps(chartProps).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual([MONDAYS[0], MONDAYS[1]]);
|
||||
});
|
||||
|
||||
test('leaves grains ECharts places correctly untouched', () => {
|
||||
(
|
||||
[
|
||||
TimeGranularity.DAY,
|
||||
TimeGranularity.MONTH,
|
||||
TimeGranularity.QUARTER,
|
||||
TimeGranularity.YEAR,
|
||||
undefined,
|
||||
] as const
|
||||
).forEach(grain => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({ timeGrainSqla: grain }),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisTick?.customValues).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('leaves a categorical x-axis untouched', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({ xAxisForceCategorical: true }),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Category);
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip for metrics whose labels end in forecast suffixes', () => {
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
|
||||
@@ -3036,3 +2766,119 @@ describe('tooltip for metrics whose labels end in forecast suffixes', () => {
|
||||
expect(html).toContain('>ci<');
|
||||
});
|
||||
});
|
||||
|
||||
test('shows gridlines and axis ticks by default', () => {
|
||||
const { echartOptions } = transformProps(createTestChartProps({}));
|
||||
const xAxis = echartOptions.xAxis as any;
|
||||
const yAxis = echartOptions.yAxis as any;
|
||||
|
||||
expect(yAxis.splitLine.show).toBe(true);
|
||||
expect(yAxis.axisTick.show).toBe(true);
|
||||
// Left to ECharts, which draws no ticks on a banded category axis. Forcing
|
||||
// true would add ticks the chart does not have today.
|
||||
expect(xAxis.axisTick.show).toBe('auto');
|
||||
});
|
||||
|
||||
test('hides gridlines without touching the minor split lines', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
createTestChartProps({ formData: { gridlines: false } }),
|
||||
);
|
||||
const yAxis = echartOptions.yAxis as any;
|
||||
|
||||
expect(yAxis.splitLine.show).toBe(false);
|
||||
expect(yAxis.minorSplitLine.show).toBe(DEFAULT_FORM_DATA.minorSplitLine);
|
||||
expect(yAxis.axisTick.show).toBe(true);
|
||||
});
|
||||
|
||||
test('leaves the category axis split lines alone until gridlines are turned off', () => {
|
||||
const shown = transformProps(createTestChartProps({}));
|
||||
// Writing show:true here would draw gridlines on axis types that default to
|
||||
// none, so the key is only ever added to hide them.
|
||||
expect((shown.echartOptions.xAxis as any).splitLine).toBeUndefined();
|
||||
|
||||
const hidden = transformProps(
|
||||
createTestChartProps({ formData: { gridlines: false } }),
|
||||
);
|
||||
expect((hidden.echartOptions.xAxis as any).splitLine.show).toBe(false);
|
||||
});
|
||||
|
||||
test('hides the ticks on both axes', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
createTestChartProps({ formData: { axisTicks: false } }),
|
||||
);
|
||||
|
||||
expect((echartOptions.yAxis as any).axisTick.show).toBe(false);
|
||||
expect((echartOptions.xAxis as any).axisTick.show).toBe(false);
|
||||
expect((echartOptions.yAxis as any).splitLine.show).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps gridlines and ticks off on a compact chart even when both are enabled', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
createTestChartProps({
|
||||
height: TIMESERIES_CONSTANTS.compactChartHeight - 1,
|
||||
formData: { gridlines: true, axisTicks: true },
|
||||
}),
|
||||
);
|
||||
const yAxis = echartOptions.yAxis as any;
|
||||
|
||||
expect(yAxis.splitLine.show).toBe(false);
|
||||
expect(yAxis.axisTick.show).toBe(false);
|
||||
});
|
||||
|
||||
test('applies gridlines to the value axis after a horizontal orientation swaps it', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
createTestChartProps({
|
||||
formData: {
|
||||
orientation: OrientationType.Horizontal,
|
||||
gridlines: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// The transform swaps the axes for a horizontal chart, so the value axis —
|
||||
// and the gridlines belonging to it — end up on xAxis.
|
||||
expect((echartOptions.xAxis as any).splitLine.show).toBe(false);
|
||||
});
|
||||
|
||||
test('boundary label alignment is dropped when the orientation moves the time axis to the side', () => {
|
||||
// The alignments position labels against the left and right edges of a
|
||||
// bottom axis. A horizontal chart swaps the axes, so applying them there
|
||||
// shifts the first label out of line with the rest (#43428 follow-up).
|
||||
const monthData = [
|
||||
{ __timestamp: Date.UTC(2003, 4, 1), sales: 100 },
|
||||
{ __timestamp: Date.UTC(2003, 5, 1), sales: 200 },
|
||||
];
|
||||
const build = (orientation: OrientationType) =>
|
||||
transformProps(
|
||||
createTestChartProps({
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
orientation,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(monthData, {
|
||||
colnames: ['__timestamp', 'sales'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).echartOptions;
|
||||
|
||||
const vertical = build(OrientationType.Vertical).xAxis as any;
|
||||
expect(vertical.axisLabel.alignMinLabel).toBe('left');
|
||||
expect(vertical.axisLabel.alignMaxLabel).toBe('right');
|
||||
|
||||
// Horizontal swaps the axes, so the time axis is now yAxis.
|
||||
const horizontal = build(OrientationType.Horizontal).yAxis as any;
|
||||
expect(horizontal.axisLabel.alignMinLabel).toBeUndefined();
|
||||
expect(horizontal.axisLabel.alignMaxLabel).toBeUndefined();
|
||||
|
||||
// The boundary labels themselves stay forced in both orientations.
|
||||
expect(vertical.axisLabel.showMinLabel).toBe(true);
|
||||
expect(vertical.axisLabel.showMaxLabel).toBe(true);
|
||||
expect(horizontal.axisLabel.showMinLabel).toBe(true);
|
||||
expect(horizontal.axisLabel.showMaxLabel).toBe(true);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
DataRecord,
|
||||
getNumberFormatter,
|
||||
getTimeFormatter,
|
||||
TimeGranularity,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme as theme } from '@apache-superset/core/theme';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
@@ -41,8 +40,6 @@ import {
|
||||
getLegendProps,
|
||||
getOverMaxHiddenFormatter,
|
||||
getMinAndMaxFromBounds,
|
||||
capTickMarks,
|
||||
getTemporalTickValues,
|
||||
sanitizeHtml,
|
||||
sortAndFilterSeries,
|
||||
sortRows,
|
||||
@@ -1708,129 +1705,6 @@ test('getAxisType does not coerce Numeric x-axis to Time regardless of values',
|
||||
);
|
||||
});
|
||||
|
||||
describe('getTemporalTickValues', () => {
|
||||
const xAxisLabel = '__timestamp';
|
||||
|
||||
test('returns undefined for a non-time axis', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Category,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined when there is no time grain', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
|
||||
expect(
|
||||
getTemporalTickValues(data, xAxisLabel, AxisType.Time, undefined),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for a non-weekly time grain', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.MONTH,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns sorted, de-duplicated bucket timestamps for numbers and Dates', () => {
|
||||
const t0 = Date.UTC(2026, 3, 6);
|
||||
const t1 = Date.UTC(2026, 3, 13);
|
||||
const data: DataRecord[] = [
|
||||
{ [xAxisLabel]: t1 },
|
||||
{ [xAxisLabel]: new Date(t0) },
|
||||
{ [xAxisLabel]: t0 }, // duplicate of the Date row above
|
||||
];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([t0, t1]);
|
||||
});
|
||||
|
||||
test('parses a zoned ISO string as the instant it names', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00.000Z' }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([Date.UTC(2026, 3, 6)]);
|
||||
});
|
||||
|
||||
test('parses a zone-less datetime string as local time, matching ECharts', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00' }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([new Date(2026, 3, 6, 0, 0, 0).getTime()]);
|
||||
});
|
||||
|
||||
test('parses a bare date string as local midnight, matching ECharts rather than native Date', () => {
|
||||
// `new Date('2026-04-06')` is UTC, but ECharts parses it as local time.
|
||||
// jest.config.js fixes the test TZ to America/New_York, so they disagree.
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06' }];
|
||||
const localMidnight = new Date(2026, 3, 6).getTime();
|
||||
expect(localMidnight).not.toEqual(new Date('2026-04-06').getTime());
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([localMidnight]);
|
||||
});
|
||||
|
||||
test('drops unparseable or nullish values and returns undefined when none remain', () => {
|
||||
const data: DataRecord[] = [
|
||||
{ [xAxisLabel]: 'not-a-date' },
|
||||
{ [xAxisLabel]: null },
|
||||
];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capTickMarks', () => {
|
||||
test('returns values unchanged when within the cap', () => {
|
||||
const values = [1, 2, 3];
|
||||
expect(capTickMarks(values, 60)).toEqual(values);
|
||||
});
|
||||
|
||||
test('downsamples evenly and always keeps the last value', () => {
|
||||
const values = Array.from({ length: 261 }, (_, i) => i);
|
||||
const capped = capTickMarks(values, 60);
|
||||
expect(capped.length).toBeLessThanOrEqual(60);
|
||||
expect(capped[0]).toEqual(0);
|
||||
expect(capped[capped.length - 1]).toEqual(260);
|
||||
});
|
||||
});
|
||||
|
||||
test('getMinAndMaxFromBounds returns empty object when not truncating', () => {
|
||||
expect(
|
||||
getMinAndMaxFromBounds(
|
||||
|
||||
@@ -462,13 +462,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
|
||||
// only take relevant page size options
|
||||
const pageSizeOptions = useMemo(() => {
|
||||
const getServerPagination = (n: number) => n <= rowCount;
|
||||
const getServerPagination = (n: number) =>
|
||||
n <= Math.max(rowCount, serverPageLength);
|
||||
return (
|
||||
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
|
||||
).filter(([n]) =>
|
||||
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
|
||||
) as SizeOption[];
|
||||
}, [data.length, rowCount, serverPagination]);
|
||||
}, [data.length, rowCount, serverPageLength, serverPagination]);
|
||||
|
||||
const getValueRange = useCallback(
|
||||
function getValueRange(key: string, alignPositiveNegative: boolean) {
|
||||
|
||||
+135
-8
@@ -67,14 +67,23 @@ async function renderAndWait(props = mockedProps) {
|
||||
container = renderedContainer;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// A modal that wasn't handed an `etag` reads the dataset itself and can't save
|
||||
// until that lands, so tests must wait before acting on the Save button.
|
||||
async function waitForSaveEnabled() {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('datasource-modal-save')).toBeEnabled(),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
cleanup();
|
||||
renderAndWait();
|
||||
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
|
||||
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
|
||||
fetchMock.get(GET_DATASOURCE_ENDPOINT, { result: {} });
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
renderAndWait();
|
||||
await waitForSaveEnabled();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
@@ -118,6 +127,7 @@ describe('DatasourceModal', () => {
|
||||
onDatasourceSave:
|
||||
onDatasourceSave as unknown as typeof mockedProps.onDatasourceSave,
|
||||
});
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
const okButton = await screen.findByRole('button', { name: 'Confirm' });
|
||||
@@ -151,6 +161,96 @@ describe('DatasourceModal', () => {
|
||||
putSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('sends the supplied etag as If-Match so a stale save is refused', async () => {
|
||||
cleanup();
|
||||
renderAndWait({ ...mockedProps, etag: '"v1"' } as typeof mockedProps);
|
||||
|
||||
fireEvent.click(screen.getByTestId('datasource-modal-save'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.callHistory
|
||||
.calls()
|
||||
.find(call => call.options?.method === 'put');
|
||||
expect(
|
||||
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
|
||||
).toEqual('"v1"');
|
||||
});
|
||||
});
|
||||
|
||||
test('reads the etag from the dataset when the caller supplies none', async () => {
|
||||
cleanup();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
|
||||
fetchMock.get(GET_DATASOURCE_ENDPOINT, {
|
||||
body: { result: {} },
|
||||
headers: { ETag: '"v2"' },
|
||||
});
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
renderAndWait();
|
||||
|
||||
// The form is seeded from the same read as the validator, so saving is
|
||||
// unavailable until it lands.
|
||||
expect(screen.getByTestId('datasource-modal-save')).toBeDisabled();
|
||||
await screen.findByTestId('datasource-editor');
|
||||
|
||||
fireEvent.click(screen.getByTestId('datasource-modal-save'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.callHistory
|
||||
.calls()
|
||||
.find(call => call.options?.method === 'put');
|
||||
expect(
|
||||
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
|
||||
).toEqual('"v2"');
|
||||
});
|
||||
});
|
||||
|
||||
test('never saves unguarded while the validator read is in flight', async () => {
|
||||
cleanup();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
|
||||
// A read that never resolves: the save path must stay closed rather than
|
||||
// fall through to an unconditional PUT.
|
||||
fetchMock.get(GET_DATASOURCE_ENDPOINT, new Promise(() => {}));
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
renderAndWait();
|
||||
|
||||
const saveButton = await screen.findByTestId('datasource-modal-save');
|
||||
expect(saveButton).toBeDisabled();
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(
|
||||
fetchMock.callHistory
|
||||
.calls()
|
||||
.find(call => call.options?.method === 'put'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('shows a conflict dialog instead of a generic error on 412', async () => {
|
||||
const putSpy = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockRejectedValue(new Response('', { status: 412 }));
|
||||
|
||||
try {
|
||||
fireEvent.click(screen.getByTestId('datasource-modal-save'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
||||
|
||||
const conflictElements = await screen.findAllByText(
|
||||
'Dataset changed since you opened it',
|
||||
);
|
||||
expect(conflictElements.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.queryByText('Error saving dataset'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
putSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows sync columns checkbox when SQL changes', async () => {
|
||||
cleanup();
|
||||
const datasourceWithSQL = {
|
||||
@@ -163,15 +263,24 @@ describe('DatasourceModal', () => {
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL
|
||||
rerender(
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
@@ -208,15 +317,24 @@ describe('DatasourceModal', () => {
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL to trigger checkbox
|
||||
rerender(
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
@@ -269,15 +387,24 @@ describe('DatasourceModal', () => {
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL to trigger checkbox
|
||||
rerender(
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
|
||||
+4
@@ -21,6 +21,7 @@ import {
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
waitFor,
|
||||
defaultStore as store,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
@@ -72,6 +73,9 @@ test('DatasourceModal - should handle sync columns state without imperative moda
|
||||
render(<DatasourceModal {...mockedProps} />, { store });
|
||||
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
// The modal fetches the current dataset version on open; save stays disabled
|
||||
// until that settles
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
|
||||
// This should not throw any DOM errors
|
||||
await act(async () => {
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
Icons,
|
||||
Button,
|
||||
Checkbox,
|
||||
Loading,
|
||||
Modal,
|
||||
AsyncEsmComponent,
|
||||
} from '@superset-ui/core/components';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { ErrorMessageWithStackTrace } from 'src/components';
|
||||
import type { DatasetObject } from 'src/features/datasets/types';
|
||||
import { withCertificationFields } from '../utils';
|
||||
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
|
||||
import type { DatasourceModalProps } from '../types';
|
||||
|
||||
@@ -91,12 +93,18 @@ export function buildExtraJsonObject(
|
||||
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
addSuccessToast,
|
||||
datasource,
|
||||
etag,
|
||||
onDatasourceSave,
|
||||
onHide,
|
||||
show,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [currentDatasource, setCurrentDatasource] = useState(datasource);
|
||||
// SQL of the server snapshot the form started from. The caller's, unless
|
||||
// this modal read the dataset itself — then "did the SQL change?" has to be
|
||||
// asked against the snapshot the payload is actually built from.
|
||||
const [seededSql, setSeededSql] = useState<string | undefined>();
|
||||
const [versionEtag, setVersionEtag] = useState(etag);
|
||||
const [syncColumns, setSyncColumns] = useState(false);
|
||||
const currencies = useSelector<
|
||||
{
|
||||
@@ -111,6 +119,52 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
const [isLoadingDatasource, setIsLoadingDatasource] = useState(false);
|
||||
|
||||
// Callers that read the dataset themselves (the dataset list) hand down the
|
||||
// ETag of that read. The rest — Explore, where `datasource` comes from the
|
||||
// page's bootstrap state — read it here, and must seed the form from the
|
||||
// *same* response: a payload built from an older snapshot than the ETag
|
||||
// guarding it would still be accepted, and would still clobber.
|
||||
useEffect(() => {
|
||||
setVersionEtag(etag);
|
||||
if (etag || !show || !datasource.id) {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsLoadingDatasource(true);
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${datasource.id}`,
|
||||
})
|
||||
.then(({ json, response }) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const seeded = {
|
||||
...datasource,
|
||||
...json.result,
|
||||
columns: withCertificationFields(json.result.columns),
|
||||
};
|
||||
setSeededSql(seeded.sql);
|
||||
setCurrentDatasource(seeded);
|
||||
setVersionEtag(response.headers.get('ETag') ?? undefined);
|
||||
})
|
||||
.catch(() => {
|
||||
// The read failed outright, so there is no fresher snapshot to edit
|
||||
// and no validator to send. Fall back to the caller's snapshot and an
|
||||
// unconditional save, which is what this modal did before the guard.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingDatasource(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [datasource.id, etag, show]);
|
||||
const baselineSql = seededSql ?? datasource.sql;
|
||||
|
||||
const buildPayload = (datasource: Record<string, any>) => {
|
||||
const payload: Record<string, any> = {
|
||||
table_name: datasource.table_name,
|
||||
@@ -197,11 +251,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${syncColumns}`,
|
||||
jsonPayload: buildPayload(currentDatasource),
|
||||
...(versionEtag ? { headers: { 'If-Match': versionEtag } } : {}),
|
||||
});
|
||||
|
||||
const { json } = await SupersetClient.get({
|
||||
const { json, response } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
|
||||
});
|
||||
setVersionEtag(response.headers.get('ETag') ?? undefined);
|
||||
|
||||
addSuccessToast(t('The dataset has been saved'));
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
@@ -213,6 +269,19 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
onHide();
|
||||
} catch (response) {
|
||||
setIsSaving(false);
|
||||
if ((response as Response)?.status === 412) {
|
||||
modal.error({
|
||||
title: t('Dataset changed since you opened it'),
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
content: t(
|
||||
'Someone else, or another one of your browser tabs, saved this ' +
|
||||
'dataset after you opened it. Saving now would undo those ' +
|
||||
'changes, so it was cancelled. Copy your edits, close this ' +
|
||||
'dialog, and reopen the dataset to reapply them.',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const error = await getClientErrorObject(response);
|
||||
let errorResponse: SupersetError | undefined;
|
||||
let errorText: string | undefined;
|
||||
@@ -264,7 +333,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
here may affect other charts
|
||||
in undesirable ways.`)}
|
||||
/>
|
||||
{datasource.sql !== currentDatasource.sql && (
|
||||
{baselineSql !== currentDatasource.sql && (
|
||||
<div
|
||||
css={theme => ({
|
||||
marginBottom: theme.marginMD,
|
||||
@@ -298,14 +367,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
{t('Are you sure you want to save and apply changes?')}
|
||||
</div>
|
||||
),
|
||||
[currentDatasource.sql, datasource.sql, syncColumns],
|
||||
[currentDatasource.sql, baselineSql, syncColumns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (datasource.sql !== currentDatasource.sql) {
|
||||
if (baselineSql !== currentDatasource.sql) {
|
||||
setSyncColumns(true);
|
||||
}
|
||||
}, [datasource.sql, currentDatasource.sql]);
|
||||
}, [baselineSql, currentDatasource.sql]);
|
||||
|
||||
const onClickSave = () => {
|
||||
setConfirmModalOpen(true);
|
||||
@@ -356,6 +425,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
onClick={onClickSave}
|
||||
disabled={
|
||||
isSaving ||
|
||||
isLoadingDatasource ||
|
||||
errors.length > 0 ||
|
||||
currentDatasource.is_managed_externally
|
||||
}
|
||||
@@ -381,14 +451,18 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
}}
|
||||
draggable
|
||||
>
|
||||
<DatasourceEditor
|
||||
showLoadingForImport
|
||||
height={500}
|
||||
datasource={currentDatasource}
|
||||
onChange={onDatasourceChange}
|
||||
setIsEditing={setIsEditing}
|
||||
currencies={currencies}
|
||||
/>
|
||||
{isLoadingDatasource ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<DatasourceEditor
|
||||
showLoadingForImport
|
||||
height={500}
|
||||
datasource={currentDatasource}
|
||||
onChange={onDatasourceChange}
|
||||
setIsEditing={setIsEditing}
|
||||
currencies={currencies}
|
||||
/>
|
||||
)}
|
||||
{contextHolder}
|
||||
<Modal
|
||||
title={t('Confirm save')}
|
||||
|
||||
@@ -20,4 +20,5 @@ import ChangeDatasourceModal from './ChangeDatasourceModal';
|
||||
import DatasourceModal from './DatasourceModal';
|
||||
|
||||
export { ChangeDatasourceModal, DatasourceModal };
|
||||
export { withCertificationFields } from './utils';
|
||||
export type { DatasourceModalProps, ChangeDatasourceModalProps } from './types';
|
||||
|
||||
@@ -29,6 +29,12 @@ export interface DatasourceModalProps {
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
datasource: DatasetObject;
|
||||
/**
|
||||
* ETag of the dataset read the form was seeded from. Replayed as `If-Match`
|
||||
* on save so a stale form can't clobber a newer write. Fetched by the modal
|
||||
* when the caller doesn't already have one.
|
||||
*/
|
||||
etag?: string;
|
||||
onChange: () => {};
|
||||
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
|
||||
onHide: () => {};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { nanoid } from 'nanoid';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { tn } from '@apache-superset/core/translation';
|
||||
import rison from 'rison';
|
||||
import type { ColumnObject } from 'src/features/datasets/types';
|
||||
|
||||
// Type definitions
|
||||
|
||||
@@ -248,3 +249,29 @@ export async function fetchSyncedColumns(
|
||||
const { json } = await SupersetClient.get({ endpoint, signal });
|
||||
return json as ColumnMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift each column's certification out of its `extra` JSON into the flat
|
||||
* fields the datasource editor binds to.
|
||||
*/
|
||||
export function withCertificationFields(columns: ColumnObject[] = []) {
|
||||
return columns.map(column => {
|
||||
// Malformed `extra` must not take out the whole column list, the way an
|
||||
// uncaught parse would — same fallback as `hydrateMetricExtra`.
|
||||
let parsedExtra;
|
||||
try {
|
||||
parsedExtra = JSON.parse(column.extra || '{}') || {};
|
||||
} catch {
|
||||
parsedExtra = {};
|
||||
}
|
||||
const {
|
||||
certification: { details = '', certified_by: certifiedBy = '' } = {},
|
||||
} = parsedExtra;
|
||||
return {
|
||||
...column,
|
||||
certification_details: details || '',
|
||||
certified_by: certifiedBy || '',
|
||||
is_certified: details || certifiedBy,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ export function handleComponentDrop(dropResult: DropResult) {
|
||||
source &&
|
||||
!(
|
||||
// ensure it has moved
|
||||
(destination.id === source.id && destination.index === source.index)
|
||||
destination.id === source.id && destination.index === source.index
|
||||
)
|
||||
) {
|
||||
dispatch(moveComponent(dropResult));
|
||||
|
||||
+56
-1
@@ -23,7 +23,7 @@ import {
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
@@ -165,6 +165,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(document, 'fullscreenElement');
|
||||
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
|
||||
// not leak into other tests (the empty array is guarded, so nothing injects).
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
});
|
||||
|
||||
test('Should render', () => {
|
||||
@@ -173,6 +176,58 @@ test('Should render', () => {
|
||||
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
|
||||
{ key: 'custom-ext', label: 'Custom Menu Extension' },
|
||||
]);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
const injected = screen.getByText('Custom Menu Extension');
|
||||
expect(injected).toBeInTheDocument();
|
||||
// Sits above the built-in entries.
|
||||
const forceRefresh = screen.getByText('Force refresh');
|
||||
expect(
|
||||
injected.compareDocumentPosition(forceRefresh) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
|
||||
// The menu still renders its built-in entries unchanged (no dangling divider
|
||||
// is added since the empty array is guarded).
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
// The throw is isolated: the built-in menu still renders.
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects nothing when the extension returns a non-array', () => {
|
||||
getExtensionsRegistry().set(
|
||||
'dashboard.slice.header.menu',
|
||||
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
|
||||
(() => undefined) as never,
|
||||
);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should render default props', () => {
|
||||
const props = createProps();
|
||||
|
||||
|
||||
@@ -34,11 +34,13 @@ import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
getExtensionsRegistry,
|
||||
VizType,
|
||||
BinaryQueryObjectFilterClause,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
@@ -165,6 +167,8 @@ const queueChartResize = () => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
const SliceHeaderControls = (
|
||||
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
|
||||
) => {
|
||||
@@ -514,6 +518,26 @@ const SliceHeaderControls = (
|
||||
},
|
||||
];
|
||||
|
||||
const sliceHeaderMenuExtension = extensionsRegistry.get(
|
||||
'dashboard.slice.header.menu',
|
||||
);
|
||||
if (sliceHeaderMenuExtension) {
|
||||
// Isolate the extension: a bad registration (throwing, or returning a
|
||||
// non-array) must not take down the whole dashboard render.
|
||||
try {
|
||||
const extensionItems = sliceHeaderMenuExtension({
|
||||
sliceId: slice.slice_id,
|
||||
sliceName: slice.slice_name,
|
||||
dashboardId,
|
||||
});
|
||||
if (Array.isArray(extensionItems) && extensionItems.length) {
|
||||
newMenuItems.unshift(...extensionItems, { type: 'divider' });
|
||||
}
|
||||
} catch (error) {
|
||||
logging.error('dashboard.slice.header.menu extension failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (slice.description) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ToggleChartDescription,
|
||||
|
||||
@@ -126,7 +126,7 @@ function fillNativeFilters(
|
||||
!(
|
||||
// Treat all-null arrays (range filters use [null, null] as their
|
||||
// canonical cleared value) and empty arrays as "no value".
|
||||
(Array.isArray(loadedValue) && loadedValue.every(v => v === null))
|
||||
Array.isArray(loadedValue) && loadedValue.every(v => v === null)
|
||||
);
|
||||
const loadedHasExtraFormData =
|
||||
!!loaded?.extraFormData && Object.keys(loaded.extraFormData).length > 0;
|
||||
|
||||
@@ -29,7 +29,9 @@ import { ControlFormItemComponents } from './ControlForm';
|
||||
* Column formatting configs.
|
||||
*/
|
||||
export type ColumnConfig = {
|
||||
[key in SharedColumnConfigProp]?: (typeof SHARED_COLUMN_CONFIG_PROPS)[key]['value'];
|
||||
[
|
||||
key in SharedColumnConfigProp
|
||||
]?: (typeof SHARED_COLUMN_CONFIG_PROPS)[key]['value'];
|
||||
} & Record<string, StrictJsonValue>;
|
||||
|
||||
/**
|
||||
|
||||
+9
-1
@@ -297,6 +297,12 @@ test('Click on Edit dataset', async () => {
|
||||
const props = createProps();
|
||||
fetchMock.removeRoute(getDbWithQuery);
|
||||
fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
|
||||
fetchMock.removeRoute(getDatasetWithAllMockRouteName);
|
||||
fetchMock.get(
|
||||
getDatasetWithAll,
|
||||
{ result: {} },
|
||||
{ name: getDatasetWithAllMockRouteName },
|
||||
);
|
||||
render(<DatasourceControl {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
@@ -307,7 +313,9 @@ test('Click on Edit dataset', async () => {
|
||||
await userEvent.click(screen.getByText('Edit dataset'));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('mock-datasource-editor')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByTestId('mock-datasource-editor'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Edit dataset should be disabled when user is not admin', async () => {
|
||||
|
||||
+164
@@ -951,3 +951,167 @@ test('filters the subject select by column verbose_name as well as column_name',
|
||||
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const COLUMN_VALUES_ENDPOINT =
|
||||
'glob:*/api/v1/datasource/*/column/value/values/*';
|
||||
|
||||
let columnValues: { result: unknown[]; limit: number } = {
|
||||
result: [],
|
||||
limit: 10000,
|
||||
};
|
||||
fetchMock.get(COLUMN_VALUES_ENDPOINT, () => columnValues);
|
||||
|
||||
const setupWithFilterValues = (result: unknown[], limit = 10000) => {
|
||||
columnValues = { result, limit };
|
||||
const onChange = jest.fn();
|
||||
const validHandler = jest.fn();
|
||||
const spy = jest.spyOn(redux, 'useSelector');
|
||||
spy.mockReturnValue({});
|
||||
const props = {
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.In,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
|
||||
comparator: [],
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
onChange,
|
||||
options,
|
||||
datasource: {
|
||||
...TestDataset,
|
||||
columns: [{ column_name: 'value', type: 'VARCHAR', id: 3 }],
|
||||
filter_select: true,
|
||||
},
|
||||
partitionColumn: 'test',
|
||||
validHandler,
|
||||
};
|
||||
render(
|
||||
<AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
|
||||
);
|
||||
return props;
|
||||
};
|
||||
|
||||
const openComparator = async () => {
|
||||
const comparator = screen.getByRole('combobox', {
|
||||
name: 'Comparator option',
|
||||
});
|
||||
userEvent.click(comparator);
|
||||
return comparator;
|
||||
};
|
||||
|
||||
test('loads comparator values from the server', async () => {
|
||||
setupWithFilterValues(['alpha', 'beta']);
|
||||
await openComparator();
|
||||
expect(await screen.findByTitle('alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sends the typed text to the server rather than filtering the loaded page', async () => {
|
||||
// The loaded page is bounded, so matching client-side cannot reach a value
|
||||
// beyond the row limit. The search has to reach the database.
|
||||
setupWithFilterValues(['alpha']);
|
||||
const comparator = await openComparator();
|
||||
userEvent.type(comparator, 'gamma');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const searched = fetchMock.callHistory
|
||||
.calls(COLUMN_VALUES_ENDPOINT)
|
||||
.map(call => String(call.url));
|
||||
expect(searched.some(url => url.includes('q=gamma'))).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
test('lets a value the server did not return still be selected', async () => {
|
||||
// Even with server-side search a match can fall outside the page; typing the
|
||||
// exact value has to remain a way through.
|
||||
setupWithFilterValues([]);
|
||||
const comparator = await openComparator();
|
||||
userEvent.type(comparator, 'not-in-the-page');
|
||||
expect(await screen.findByTitle('not-in-the-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not query for values when the dataset disables them', async () => {
|
||||
fetchMock.clearHistory();
|
||||
setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.In,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
|
||||
comparator: [],
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
});
|
||||
await openComparator();
|
||||
expect(fetchMock.callHistory.calls(COLUMN_VALUES_ENDPOINT)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('stores the picked value, not the option object', async () => {
|
||||
// AsyncSelect is labelInValue: taking its argument at face value puts
|
||||
// {label, value} into the comparator, and the engine then fails to render it
|
||||
// as a literal.
|
||||
const props = setupWithFilterValues(['Michael']);
|
||||
await openComparator();
|
||||
userEvent.click(await screen.findByTitle('Michael'));
|
||||
|
||||
await waitFor(() => expect(props.onChange).toHaveBeenCalled());
|
||||
const [filter] = props.onChange.mock.calls.at(-1);
|
||||
expect(filter.comparator).toEqual(['Michael']);
|
||||
});
|
||||
|
||||
test('can remove a value that was saved earlier', async () => {
|
||||
// Reopening the popover restores the comparator from the saved filter, and
|
||||
// the value is not in the freshly loaded page. Removing it has to still work.
|
||||
columnValues = { result: [], limit: 10000 };
|
||||
const onChange = jest.fn();
|
||||
const validHandler = jest.fn();
|
||||
jest.spyOn(redux, 'useSelector').mockReturnValue({});
|
||||
render(
|
||||
<AdhocFilterEditPopoverSimpleTabContent
|
||||
{...({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.In,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
|
||||
comparator: ['Michael'],
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
onChange,
|
||||
options,
|
||||
datasource: {
|
||||
...TestDataset,
|
||||
columns: [{ column_name: 'value', type: 'VARCHAR', id: 3 }],
|
||||
filter_select: true,
|
||||
},
|
||||
partitionColumn: 'test',
|
||||
validHandler,
|
||||
} as unknown as Props)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Remove it the way a user does: the tag's own close control.
|
||||
userEvent.click(await screen.findByLabelText('close'));
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
||||
const [filter] = onChange.mock.calls.at(-1);
|
||||
expect(filter.comparator).toEqual([]);
|
||||
});
|
||||
|
||||
test('says the list is partial when the server capped it', async () => {
|
||||
setupWithFilterValues(['alpha', 'beta'], 2);
|
||||
await openComparator();
|
||||
expect(
|
||||
await screen.findByText(/Only the first 2 values are listed/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not say the list is partial when it is complete', async () => {
|
||||
setupWithFilterValues(['alpha', 'beta'], 10000);
|
||||
await openComparator();
|
||||
expect(await screen.findByTitle('alpha')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Only the first/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
+171
-91
@@ -16,13 +16,25 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FC, ChangeEvent, useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
FC,
|
||||
ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
AsyncSelect,
|
||||
Input,
|
||||
InputRef,
|
||||
Select,
|
||||
Tooltip,
|
||||
type AsyncSelectRef,
|
||||
type LabeledValue,
|
||||
type SelectOptionsTypePage,
|
||||
type SelectValue,
|
||||
} from '@superset-ui/core/components';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -57,7 +69,7 @@ import { useDatePickerInAdhocFilter } from '../utils';
|
||||
import { useDefaultTimeFilter } from '../../DateFilterControl/utils';
|
||||
import { Clauses, ExpressionTypes } from '../types';
|
||||
|
||||
const SelectWithLabel = styled(Select)<{ labelText: string }>`
|
||||
const SelectWithLabel = styled(AsyncSelect)<{ labelText: string }>`
|
||||
.ant-select-content::after {
|
||||
content: ${({ labelText }) => labelText || '\\A0'};
|
||||
display: inline-block;
|
||||
@@ -67,6 +79,30 @@ const SelectWithLabel = styled(Select)<{ labelText: string }>`
|
||||
}
|
||||
`;
|
||||
|
||||
// The server answers with one bounded page, not an offset window: paging would
|
||||
// need a stable ORDER BY, and ordering a high-cardinality column is the full
|
||||
// scan this search exists to avoid. A page size no response can reach keeps
|
||||
// AsyncSelect from asking for a second page.
|
||||
const COMPARATOR_PAGE_SIZE = 1_000_000;
|
||||
|
||||
const toLabeledValue = (value: unknown): LabeledValue => ({
|
||||
value: value as LabeledValue['value'],
|
||||
label: optionLabel(value as null | number | boolean | string),
|
||||
});
|
||||
|
||||
// The reverse of toLabeledValue: what AsyncSelect emits is labelled, and the
|
||||
// comparator has to be the raw value or the engine cannot render it as a
|
||||
// literal.
|
||||
const unwrapComparator = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(unwrapComparator);
|
||||
}
|
||||
if (value !== null && typeof value === 'object' && 'value' in value) {
|
||||
return (value as LabeledValue).value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export interface SimpleExpressionType {
|
||||
expressionType: keyof typeof ExpressionTypes;
|
||||
column: ColumnMeta;
|
||||
@@ -347,11 +383,9 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
} = useSimpleTabFilterProps(props);
|
||||
const [comparator, setComparator] = useState(props.adhocFilter.comparator);
|
||||
const comparatorInputRef = useRef<InputRef | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<
|
||||
Record<'label' | 'value', any>[]
|
||||
>([]);
|
||||
const [loadingComparatorSuggestions, setLoadingComparatorSuggestions] =
|
||||
useState<boolean>(false);
|
||||
const comparatorSelectRef = useRef<AsyncSelectRef>(null);
|
||||
const [loadedOptionCount, setLoadedOptionCount] = useState(0);
|
||||
const [optionsTruncated, setOptionsTruncated] = useState(false);
|
||||
const [hasFocusedComparator, setHasFocusedComparator] =
|
||||
useState<boolean>(false);
|
||||
|
||||
@@ -387,18 +421,8 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
/>
|
||||
);
|
||||
|
||||
const getOptionsRemaining = () => {
|
||||
// if select is multi/value is array, we show the options not selected
|
||||
const valuesFromSuggestionsLength = Array.isArray(comparator)
|
||||
? comparator.filter(v => suggestions.includes(v)).length
|
||||
: 0;
|
||||
return suggestions ? suggestions.length - valuesFromSuggestionsLength : 0;
|
||||
};
|
||||
const createSuggestionsPlaceholder = () => {
|
||||
const optionsRemaining = getOptionsRemaining();
|
||||
const placeholder = t('%s option(s)', optionsRemaining);
|
||||
return optionsRemaining ? placeholder : '';
|
||||
};
|
||||
const createSuggestionsPlaceholder = () =>
|
||||
loadedOptionCount ? t('%s option(s)', loadedOptionCount) : '';
|
||||
|
||||
const handleSubjectChange = (subject: string) => {
|
||||
setComparator(undefined);
|
||||
@@ -455,21 +479,63 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
operatorId !== undefined &&
|
||||
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators);
|
||||
|
||||
const canSuggestComparatorValues = Boolean(
|
||||
subjectString &&
|
||||
props.datasource?.filter_select &&
|
||||
props.adhocFilter.clause !== Clauses.Having,
|
||||
);
|
||||
|
||||
const hasComparatorOptions =
|
||||
(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
|
||||
suggestions.length > 0;
|
||||
canSuggestComparatorValues;
|
||||
|
||||
// AsyncSelect is labelInValue, so the value it is given has to be labelled
|
||||
// too. Handed a bare value it still renders, but `handleOnDeselect` then
|
||||
// compares `element.value` against entries that have no `.value`, matches
|
||||
// nothing, and the tag cannot be removed.
|
||||
//
|
||||
// Memoised because AsyncSelect resets its internal selection whenever the
|
||||
// identity of `value` changes. A fresh array every render would wipe out
|
||||
// each pick as soon as it was made.
|
||||
const comparatorSelectValue = useMemo(
|
||||
() =>
|
||||
Array.isArray(comparator)
|
||||
? comparator.map(toLabeledValue)
|
||||
: isDefined(comparator) && comparator !== ''
|
||||
? toLabeledValue(comparator)
|
||||
: undefined,
|
||||
[comparator],
|
||||
);
|
||||
|
||||
const handleComparatorChange = useCallback(
|
||||
(value: unknown) => {
|
||||
onComparatorChange(unwrapComparator(value) as string);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.adhocFilter, props.onChange],
|
||||
);
|
||||
|
||||
const comparatorSelectProps = {
|
||||
allowClear: true,
|
||||
allowNewOptions: true,
|
||||
ariaLabel: t('Comparator option'),
|
||||
pageSize: COMPARATOR_PAGE_SIZE,
|
||||
// A capped list reads as the whole set unless it says otherwise, so an
|
||||
// absent value looks like a value that does not exist. Only shown when the
|
||||
// list is actually cut short.
|
||||
helperText: optionsTruncated
|
||||
? t(
|
||||
'Only the first %s values are listed. Type to search all of them, ' +
|
||||
'or enter a value that is not listed.',
|
||||
loadedOptionCount,
|
||||
)
|
||||
: undefined,
|
||||
mode:
|
||||
operatorId && MULTI_OPERATORS.has(operatorId as Operators)
|
||||
? ('multiple' as const)
|
||||
: ('single' as const),
|
||||
loading: loadingComparatorSuggestions,
|
||||
value: comparator as SelectValue,
|
||||
onChange: onComparatorChange,
|
||||
value: comparatorSelectValue as SelectValue,
|
||||
onChange: handleComparatorChange,
|
||||
notFoundContent: t('Type a value here'),
|
||||
placeholder: createSuggestionsPlaceholder(),
|
||||
};
|
||||
@@ -495,76 +561,89 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
onChange: onDatePickerChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const refreshComparatorSuggestions = () => {
|
||||
const { datasource } = props;
|
||||
const col = props.adhocFilter.subject;
|
||||
const having = props.adhocFilter.clause === Clauses.Having;
|
||||
// Element-level array operators (Contains any / Contains all) search inside
|
||||
// the array, so suggest individual elements; whole-array operators (=, In, …)
|
||||
// keep the default distinct-array suggestions.
|
||||
const arrayElements =
|
||||
props.adhocFilter.operatorId === Operators.ContainsAny ||
|
||||
props.adhocFilter.operatorId === Operators.ContainsAll;
|
||||
|
||||
if (col && datasource && datasource.filter_select && !having) {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
if (loadingComparatorSuggestions) {
|
||||
controller.abort();
|
||||
}
|
||||
// Element-level array operators (Contains any / Contains all) search
|
||||
// inside the array, so suggest individual elements; whole-array
|
||||
// operators (=, In, …) keep the default distinct-array suggestions.
|
||||
const { operatorId } = props.adhocFilter;
|
||||
const arrayElements =
|
||||
operatorId === Operators.ContainsAny ||
|
||||
operatorId === Operators.ContainsAll;
|
||||
setLoadingComparatorSuggestions(true);
|
||||
SupersetClient.get({
|
||||
signal,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
|
||||
arrayElements ? '?array_elements=true' : ''
|
||||
}`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setSuggestions(
|
||||
json.result.map((suggestion: unknown) => {
|
||||
// Complex column values arrive as JS arrays or objects: whole
|
||||
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
|
||||
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
|
||||
// A raw array/object is neither a valid single-select value
|
||||
// (antd collapses an array to its first element) nor renderable
|
||||
// as a React child (an object throws). Render it as its literal
|
||||
// string, which is also exactly what the backend's
|
||||
// parse_array_literal expects for the whole-array operators.
|
||||
if (suggestion !== null && typeof suggestion === 'object') {
|
||||
const literal = JSON.stringify(suggestion);
|
||||
return { value: literal, label: literal };
|
||||
}
|
||||
return {
|
||||
value: suggestion as null | number | boolean | string,
|
||||
label: optionLabel(
|
||||
suggestion as null | number | boolean | string,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setLoadingComparatorSuggestions(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setSuggestions([]);
|
||||
setLoadingComparatorSuggestions(false);
|
||||
});
|
||||
// AsyncSelect throws away every loaded option when the identity of its
|
||||
// `options` callback changes, so this depends on plain values rather than on
|
||||
// `props.datasource`, whose identity the parent does not guarantee.
|
||||
const datasourceType = props.datasource?.type;
|
||||
const datasourceId = props.datasource?.id;
|
||||
|
||||
const loadComparatorOptions = useCallback(
|
||||
async (search: string): Promise<SelectOptionsTypePage> => {
|
||||
const col = subjectString;
|
||||
if (!col || !canSuggestComparatorValues) {
|
||||
return { data: [], totalCount: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
if (!datePicker) {
|
||||
refreshComparatorSuggestions();
|
||||
}
|
||||
// loadingComparatorSuggestions intentionally omitted - set inside effect, would cause infinite loop
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.adhocFilter.subject,
|
||||
props.adhocFilter.clause,
|
||||
props.adhocFilter.operatorId,
|
||||
props.datasource,
|
||||
datePicker,
|
||||
]);
|
||||
const params = new URLSearchParams();
|
||||
if (arrayElements) {
|
||||
params.set('array_elements', 'true');
|
||||
}
|
||||
if (search) {
|
||||
params.set('q', search);
|
||||
}
|
||||
const query = params.toString();
|
||||
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint:
|
||||
`/api/v1/datasource/${datasourceType}/${datasourceId}` +
|
||||
`/column/${encodeURIComponent(col)}/values/${query ? `?${query}` : ''}`,
|
||||
});
|
||||
const data = json.result.map((suggestion: unknown) => {
|
||||
// Complex column values arrive as JS arrays or objects: whole arrays
|
||||
// for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple objects for
|
||||
// nested-container columns (e.g. {"a": ["x","y"]}). A raw
|
||||
// array/object is neither a valid single-select value (antd collapses
|
||||
// an array to its first element) nor renderable as a React child (an
|
||||
// object throws). Render it as its literal string, which is also
|
||||
// exactly what the backend's parse_array_literal expects for the
|
||||
// whole-array operators.
|
||||
if (suggestion !== null && typeof suggestion === 'object') {
|
||||
const literal = JSON.stringify(suggestion);
|
||||
return { value: literal, label: literal };
|
||||
}
|
||||
return {
|
||||
value: suggestion as null | number | boolean | string,
|
||||
label: optionLabel(suggestion as null | number | boolean | string),
|
||||
};
|
||||
});
|
||||
|
||||
setLoadedOptionCount(data.length);
|
||||
setOptionsTruncated(isDefined(json.limit) && data.length >= json.limit);
|
||||
|
||||
// The count has to exceed what was returned. AsyncSelect treats
|
||||
// `loaded >= totalCount` as "that is every value", sets allValuesLoaded
|
||||
// and from then on serves searches by filtering the loaded page
|
||||
// client-side -- which is the behaviour this whole change exists to
|
||||
// replace. Pagination is held off by COMPARATOR_PAGE_SIZE instead.
|
||||
return { data, totalCount: data.length + 1 };
|
||||
} catch {
|
||||
setLoadedOptionCount(0);
|
||||
setOptionsTruncated(false);
|
||||
return { data: [], totalCount: 0 };
|
||||
}
|
||||
},
|
||||
[
|
||||
subjectString,
|
||||
canSuggestComparatorValues,
|
||||
datasourceType,
|
||||
datasourceId,
|
||||
arrayElements,
|
||||
],
|
||||
);
|
||||
|
||||
// Options are cached per search term inside AsyncSelect; a different column
|
||||
// or a switch to element-level suggestions invalidates all of them.
|
||||
useEffect(() => {
|
||||
comparatorSelectRef.current?.clearCache();
|
||||
}, [subjectString, arrayElements]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)) {
|
||||
@@ -670,11 +749,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
}
|
||||
>
|
||||
<SelectWithLabel
|
||||
ref={comparatorSelectRef}
|
||||
css={css`
|
||||
margin-top: ${theme.marginXS}px;
|
||||
`}
|
||||
labelText={labelText}
|
||||
options={suggestions}
|
||||
options={loadComparatorOptions}
|
||||
{...comparatorSelectProps}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -879,10 +879,41 @@ describe('SelectFilterPlugin', () => {
|
||||
expect(await screen.findByTitle('brand-new')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show create option when searchAllOptions is true', () => {
|
||||
test('says the list is capped when it hits the row limit', async () => {
|
||||
// 3 rows of data against a limit of 3: the user is looking at a page, not
|
||||
// at every value the column has.
|
||||
getWrapper({ rowLimit: 3 });
|
||||
userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
expect(
|
||||
await screen.findByText(/Only the first 3 values are listed/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('offers the ways out that the filter actually supports', async () => {
|
||||
getWrapper({ rowLimit: 3, creatable: true, searchAllOptions: true });
|
||||
userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
expect(
|
||||
await screen.findByText(/Type to search all of them/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/You can enter a value that is not listed/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('says nothing when the whole column fits under the limit', async () => {
|
||||
getWrapper();
|
||||
userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
expect(await screen.findByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Only the first/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows create option when searchAllOptions is true', async () => {
|
||||
// Server-side search returns a bounded page, so a value that exists in the
|
||||
// data can still be missing from the dropdown. Suppressing the create
|
||||
// option there leaves the user with no way to apply it at all.
|
||||
getWrapper({ creatable: true, searchAllOptions: true });
|
||||
userEvent.type(screen.getByRole('combobox'), 'brand-new');
|
||||
expect(screen.queryByTitle('brand-new')).not.toBeInTheDocument();
|
||||
expect(await screen.findByTitle('brand-new')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -271,7 +271,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
type: 'ownState',
|
||||
ownState: {
|
||||
coltypeMap: initialColtypeMap,
|
||||
search,
|
||||
// The dropdown offers `stripSurroundingQuotes(search)` as the
|
||||
// creatable option, so the server has to be asked for the same
|
||||
// string or the two disagree about what was searched for.
|
||||
search: stripSurroundingQuotes(search).trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -281,8 +284,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
unsetFocusedFilter();
|
||||
onSearch('');
|
||||
}, [onSearch, unsetFocusedFilter]);
|
||||
if (search) {
|
||||
onSearch('');
|
||||
}
|
||||
}, [onSearch, search, unsetFocusedFilter]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value?: SelectValue | number | string) => {
|
||||
@@ -304,6 +309,25 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
? t('No data')
|
||||
: tn('%s option', '%s options', data.length, data.length);
|
||||
|
||||
// A capped list reads as the whole set, so a value sitting past the row
|
||||
// limit looks like a value that does not exist. Each sentence is only added
|
||||
// when it is actually true of this filter's configuration.
|
||||
const rowLimit = Number(formData.rowLimit) || 0;
|
||||
const helperText = useMemo(() => {
|
||||
if (!rowLimit || data.length < rowLimit) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
t('Only the first %s values are listed.', data.length),
|
||||
searchAllOptions ? t('Type to search all of them.') : undefined,
|
||||
creatable !== false
|
||||
? t('You can enter a value that is not listed.')
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}, [creatable, data.length, rowLimit, searchAllOptions]);
|
||||
|
||||
const formItemExtra = useMemo(() => {
|
||||
if (filterState.validateMessage) {
|
||||
return (
|
||||
@@ -336,7 +360,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
const unquotedSearch = stripSurroundingQuotes(search);
|
||||
if (
|
||||
unquotedSearch &&
|
||||
!searchAllOptions &&
|
||||
creatable !== false &&
|
||||
!hasOption(unquotedSearch, uniqueOptions, true)
|
||||
) {
|
||||
@@ -346,7 +369,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
];
|
||||
}
|
||||
return uniqueOptions;
|
||||
}, [search, uniqueOptions, creatable, searchAllOptions]);
|
||||
}, [search, uniqueOptions, creatable]);
|
||||
|
||||
const sortComparator = useCallback(
|
||||
(a: LabeledValue, b: LabeledValue) => {
|
||||
@@ -617,7 +640,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
name={formData.nativeFilterId}
|
||||
allowClear
|
||||
autoClearSearchValue
|
||||
allowNewOptions={!searchAllOptions && creatable !== false}
|
||||
allowNewOptions={creatable !== false}
|
||||
allowNewOptionsOnPaste={multiSelect && searchAllOptions}
|
||||
allowSelectAll={!searchAllOptions}
|
||||
value={multiSelect ? filterState.value || [] : filterState.value}
|
||||
@@ -626,6 +649,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
showSearch={showSearch}
|
||||
mode={multiSelect ? 'multiple' : 'single'}
|
||||
placeholder={placeholderText}
|
||||
helperText={helperText}
|
||||
onClear={() => onSearch('')}
|
||||
onSearch={onSearch}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -117,6 +117,38 @@ describe('Select buildQuery', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('should not sort by the searched column', () => {
|
||||
// Ordering by a high-cardinality column makes the engine sort every match
|
||||
// before applying the row limit; the dropdown re-sorts the page anyway.
|
||||
const queryContext = buildQuery(
|
||||
{ ...formData, sortAscending: true },
|
||||
{
|
||||
ownState: {
|
||||
search: 'abc',
|
||||
coltypeMap: { my_col: GenericDataType.String },
|
||||
},
|
||||
},
|
||||
);
|
||||
const [query] = queryContext.queries;
|
||||
expect(query.orderby).toEqual([]);
|
||||
});
|
||||
|
||||
test('should keep the sort metric while searching', () => {
|
||||
// A sort metric decides which rows come back, so dropping it would change
|
||||
// the result set rather than just its order.
|
||||
const queryContext = buildQuery(
|
||||
{ ...formData, sortMetric: 'my_metric', sortAscending: false },
|
||||
{
|
||||
ownState: {
|
||||
search: 'abc',
|
||||
coltypeMap: { my_col: GenericDataType.String },
|
||||
},
|
||||
},
|
||||
);
|
||||
const [query] = queryContext.queries;
|
||||
expect(query.orderby).toEqual([['my_metric', false]]);
|
||||
});
|
||||
|
||||
test('should add text search parameter for numeric to query filter', () => {
|
||||
const queryContext = buildQuery(formData, {
|
||||
ownState: {
|
||||
|
||||
@@ -54,6 +54,13 @@ const buildQuery: BuildQuery<PluginFilterSelectQueryFormData> = (
|
||||
}
|
||||
|
||||
const sortColumns = sortMetric ? [sortMetric] : columns;
|
||||
// Sorting by the searched column makes the engine scan and sort every
|
||||
// match before applying the row limit, which is the dominant cost of
|
||||
// search-as-you-type on a high-cardinality column. The dropdown re-sorts
|
||||
// the returned page client-side, so the server sort buys nothing here. A
|
||||
// sort metric is different: it selects *which* rows come back, so it has
|
||||
// to stay.
|
||||
const skipOrderBy = !!search && !sortMetric;
|
||||
const query: QueryObject[] = [
|
||||
{
|
||||
...baseQueryObject,
|
||||
@@ -61,7 +68,7 @@ const buildQuery: BuildQuery<PluginFilterSelectQueryFormData> = (
|
||||
metrics: sortMetric ? [sortMetric] : [],
|
||||
filters: filters.concat(extraFilters),
|
||||
orderby:
|
||||
sortMetric || sortAscending !== undefined
|
||||
!skipOrderBy && (sortMetric || sortAscending !== undefined)
|
||||
? sortColumns.map(column => [column, !!sortAscending])
|
||||
: [],
|
||||
},
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
|
||||
import { SubjectPile } from 'src/features/subjects/SubjectPile';
|
||||
import { ColumnObject } from 'src/features/datasets/types';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import {
|
||||
ActionButton,
|
||||
@@ -62,6 +61,7 @@ import {
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
DatasourceModal,
|
||||
withCertificationFields,
|
||||
GenericLink,
|
||||
ImportModal as ImportModelsModal,
|
||||
ModifiedInfo,
|
||||
@@ -496,6 +496,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
|
||||
const [datasetCurrentlyEditing, setDatasetCurrentlyEditing] =
|
||||
useState<Dataset | null>(null);
|
||||
const [datasetCurrentlyEditingEtag, setDatasetCurrentlyEditingEtag] =
|
||||
useState<string | undefined>();
|
||||
|
||||
const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] =
|
||||
useState<VirtualDataset | null>(null);
|
||||
@@ -565,24 +567,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${id}`,
|
||||
})
|
||||
.then(({ json = {} }) => {
|
||||
const addCertificationFields = json.result.columns.map(
|
||||
(column: ColumnObject) => {
|
||||
const {
|
||||
certification: {
|
||||
details = '',
|
||||
certified_by: certifiedBy = '',
|
||||
} = {},
|
||||
} = JSON.parse(column.extra || '{}') || {};
|
||||
return {
|
||||
...column,
|
||||
certification_details: details || '',
|
||||
certified_by: certifiedBy || '',
|
||||
is_certified: details || certifiedBy,
|
||||
};
|
||||
},
|
||||
.then(({ json = {}, response }) => {
|
||||
setDatasetCurrentlyEditingEtag(
|
||||
response.headers.get('ETag') ?? undefined,
|
||||
);
|
||||
json.result.columns = [...addCertificationFields];
|
||||
json.result.columns = withCertificationFields(json.result.columns);
|
||||
setDatasetCurrentlyEditing(json.result);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -1524,6 +1513,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
{datasetCurrentlyEditing && (
|
||||
<DatasourceModal
|
||||
datasource={datasetCurrentlyEditing}
|
||||
etag={datasetCurrentlyEditingEtag}
|
||||
onDatasourceSave={refreshData}
|
||||
onHide={closeDatasetEditModal}
|
||||
show
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
EchartsRoseChartPlugin,
|
||||
EchartsTimePivotChartPlugin,
|
||||
EchartsBulletChartPlugin,
|
||||
EchartsButterflyChartPlugin,
|
||||
} from '@superset-ui/plugin-chart-echarts';
|
||||
import {
|
||||
SelectFilterPlugin,
|
||||
@@ -163,6 +164,9 @@ export default class MainPreset extends Preset {
|
||||
new EchartsWaterfallChartPlugin().configure({
|
||||
key: VizType.Waterfall,
|
||||
}),
|
||||
new EchartsButterflyChartPlugin().configure({
|
||||
key: VizType.Butterfly,
|
||||
}),
|
||||
new EchartsHeatmapChartPlugin().configure({ key: VizType.Heatmap }),
|
||||
new EchartsHistogramChartPlugin().configure({ key: VizType.Histogram }),
|
||||
new SelectFilterPlugin().configure({ key: FilterPlugins.Select }),
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../superset-frontend/.npmrc
|
||||
Generated
+4
-4
@@ -26,7 +26,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.64.0",
|
||||
@@ -1624,9 +1624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.8.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
|
||||
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.64.0",
|
||||
|
||||
@@ -160,7 +160,7 @@ def migrate_by_id(ids: tuple[int, ...], is_downgrade: bool = False) -> None:
|
||||
"""
|
||||
Migrate a subset of charts by IDs.
|
||||
|
||||
:param id: Tuple of chart IDs to migrate
|
||||
:param ids: Tuple of chart IDs to migrate
|
||||
:param is_downgrade: Whether to downgrade the charts. Default is upgrade.
|
||||
"""
|
||||
slices = db.session.query(Slice).filter(Slice.id.in_(ids))
|
||||
|
||||
@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import (
|
||||
ChartCreateFailedError,
|
||||
ChartForbiddenError,
|
||||
ChartInvalidError,
|
||||
ChartParamsInvalidJSONValidationError,
|
||||
DashboardsForbiddenError,
|
||||
DashboardsNotFoundValidationError,
|
||||
)
|
||||
@@ -47,7 +48,12 @@ class CreateChartCommand(CreateMixin, BaseCommand):
|
||||
self._properties = data.copy()
|
||||
|
||||
if params_str := self._properties.get("params"):
|
||||
params = json.loads(params_str)
|
||||
try:
|
||||
params = json.loads(params_str)
|
||||
except json.JSONDecodeError as ex:
|
||||
raise ChartInvalidError(
|
||||
exceptions=[ChartParamsInvalidJSONValidationError()]
|
||||
) from ex
|
||||
if isinstance(params, dict) and "viz_type" in params:
|
||||
# Only fall back to params when no top-level viz_type was supplied;
|
||||
# an explicit top-level field takes precedence.
|
||||
|
||||
@@ -116,6 +116,15 @@ class ChartQueryContextDatasourceMismatchValidationError(ValidationError):
|
||||
)
|
||||
|
||||
|
||||
class ChartParamsInvalidJSONValidationError(ValidationError):
|
||||
"""
|
||||
Raised when the params field is not valid JSON.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_("Chart params contains invalid JSON"), field_name="params")
|
||||
|
||||
|
||||
class ChartNotFoundError(CommandException):
|
||||
message = "Chart not found."
|
||||
|
||||
|
||||
@@ -964,6 +964,7 @@ class AnnotationDatasource(BaseDatasource):
|
||||
limit: int = 10000,
|
||||
denormalize_column: bool = False,
|
||||
array_elements: bool = False,
|
||||
search: str | None = None,
|
||||
) -> list[Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import logging
|
||||
|
||||
from flask import Response
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
from flask_appbuilder.security.decorators import has_access_api
|
||||
|
||||
from superset.commands.dashboard.filter_state.create import CreateFilterStateCommand
|
||||
from superset.commands.dashboard.filter_state.delete import DeleteFilterStateCommand
|
||||
@@ -26,7 +25,6 @@ from superset.commands.dashboard.filter_state.get import GetFilterStateCommand
|
||||
from superset.commands.dashboard.filter_state.update import UpdateFilterStateCommand
|
||||
from superset.extensions import event_logger
|
||||
from superset.temporary_cache.api import TemporaryCacheRestApi
|
||||
from superset.views.base import api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,8 +46,6 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
|
||||
def get_delete_command(self) -> type[DeleteFilterStateCommand]:
|
||||
return DeleteFilterStateCommand
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/<int:pk>/filter_state", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@@ -173,8 +169,6 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
|
||||
"""
|
||||
return super().post(pk)
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/<int:pk>/filter_state/<string:key>", methods=("PUT",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -29,7 +29,7 @@ from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
|
||||
from flask_appbuilder.api.schemas import get_item_schema
|
||||
from flask_appbuilder.const import API_RESULT_RES_KEY, API_SELECT_COLUMNS_RIS_KEY
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_babel import ngettext
|
||||
from flask_babel import gettext as _, ngettext
|
||||
from jinja2.exceptions import TemplateError
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.orm.exc import MultipleResultsFound
|
||||
@@ -95,13 +95,20 @@ from superset.subjects.filters import FilterRelatedSubjects, subject_type_filter
|
||||
from superset.utils import json
|
||||
from superset.utils.core import parse_boolean_string, send_export_zip
|
||||
from superset.versioning.api_helpers import (
|
||||
current_entity_etag_uuid,
|
||||
concurrency_token_from,
|
||||
current_entity_version_info,
|
||||
entity_concurrency_token,
|
||||
get_version_endpoint,
|
||||
list_versions_endpoint,
|
||||
lock_entity_for_update,
|
||||
restore_version_endpoint,
|
||||
)
|
||||
from superset.versioning.etag import set_version_etag
|
||||
from superset.versioning.etag import (
|
||||
is_conditional_write,
|
||||
raise_for_stale_write,
|
||||
set_version_etag,
|
||||
StaleEntityError,
|
||||
)
|
||||
from superset.versioning.schemas import VersionListItemSchema
|
||||
from superset.views.base import DatasourceFilter
|
||||
from superset.views.base_api import (
|
||||
@@ -278,6 +285,18 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
"database.backend",
|
||||
"database.allow_multi_catalog",
|
||||
"columns.advanced_data_type",
|
||||
# Certification/warning metadata is stored serialized in the ``extra``
|
||||
# column and surfaced through model properties. Exposing them keeps this
|
||||
# payload consistent with the datasource serialization used by Explore,
|
||||
# so clients hydrating from this endpoint don't lose the badges.
|
||||
"columns.certification_details",
|
||||
"columns.certified_by",
|
||||
"columns.is_certified",
|
||||
"columns.warning_markdown",
|
||||
"metrics.certification_details",
|
||||
"metrics.certified_by",
|
||||
"metrics.is_certified",
|
||||
"metrics.warning_markdown",
|
||||
"is_managed_externally",
|
||||
"uid",
|
||||
"uuid",
|
||||
@@ -530,6 +549,14 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
schema:
|
||||
type: boolean
|
||||
name: override_columns
|
||||
- in: header
|
||||
schema:
|
||||
type: string
|
||||
name: If-Match
|
||||
description: >-
|
||||
Optional optimistic-concurrency guard. Pass the ``ETag`` returned
|
||||
by a prior read of this dataset; the update is rejected with 412
|
||||
if the dataset has changed since.
|
||||
requestBody:
|
||||
description: Dataset schema
|
||||
required: true
|
||||
@@ -606,6 +633,17 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
412:
|
||||
description: >-
|
||||
The dataset changed since the version identified by the
|
||||
request's ``If-Match`` header; the update was not applied.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
@@ -622,10 +660,32 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
# Serialise conditional saves on this dataset: the guard below reads
|
||||
# the live version, the command writes, and the two must not interleave
|
||||
# with another request's. Only a conditional save pays for the lock; an
|
||||
# unconditional PUT behaves exactly as it did before the guard existed.
|
||||
if is_conditional_write():
|
||||
lock_entity_for_update(SqlaTable, pk)
|
||||
|
||||
# Live version identifiers before the update (empty + query-free when
|
||||
# ``ENABLE_VERSIONING_CAPTURE`` is off).
|
||||
old_info = current_entity_version_info(SqlaTable, pk)
|
||||
|
||||
try:
|
||||
raise_for_stale_write(concurrency_token_from(old_info))
|
||||
except StaleEntityError:
|
||||
return set_version_etag(
|
||||
self.response(
|
||||
412,
|
||||
message=_(
|
||||
"The dataset was changed by another user or browser tab "
|
||||
"after you opened it. Reopen it to pick up the latest "
|
||||
"version, then reapply your changes."
|
||||
),
|
||||
),
|
||||
concurrency_token_from(old_info),
|
||||
)
|
||||
|
||||
try:
|
||||
# Two commands, two commits, two Continuum transactions for an
|
||||
# ``override_columns`` save — deliberately NOT merged into one
|
||||
@@ -649,13 +709,13 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
new_info = current_entity_version_info(
|
||||
SqlaTable, changed_model.id, changed_model.uuid
|
||||
)
|
||||
etag_version_uuid = new_info.version_uuid
|
||||
etag_version_uuid = concurrency_token_from(new_info)
|
||||
if override_columns:
|
||||
RefreshDatasetCommand(pk).run()
|
||||
# The ETag must reflect the entity's *current live* version,
|
||||
# which after the refresh is the refresh's transaction —
|
||||
# re-read it rather than reusing the pre-refresh uuid.
|
||||
etag_version_uuid = current_entity_etag_uuid(
|
||||
etag_version_uuid = entity_concurrency_token(
|
||||
SqlaTable, changed_model.id, changed_model.uuid
|
||||
)
|
||||
response = self.response(
|
||||
@@ -1688,7 +1748,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
|
||||
return set_version_etag(
|
||||
self.response(200, **response),
|
||||
current_entity_etag_uuid(SqlaTable, table.id, table.uuid),
|
||||
entity_concurrency_token(SqlaTable, table.id, table.uuid),
|
||||
)
|
||||
|
||||
@expose("/<int:pk>/drill_info/", methods=("GET",))
|
||||
|
||||
@@ -41,6 +41,9 @@ from superset.views.base_api import BaseSupersetApi, statsd_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache lifetime for search-filtered column values, in seconds.
|
||||
SEARCH_CACHE_TIMEOUT = 60
|
||||
|
||||
|
||||
class DatasourceRestApi(BaseSupersetApi):
|
||||
allow_browser_login = True
|
||||
@@ -87,6 +90,14 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
type: string
|
||||
name: column_name
|
||||
description: The name of the column to get values for
|
||||
- in: query
|
||||
schema:
|
||||
type: string
|
||||
name: q
|
||||
description: >-
|
||||
Optional case-insensitive substring; only values containing it are
|
||||
returned. Lets the client search the full column rather than the
|
||||
truncated first page.
|
||||
responses:
|
||||
200:
|
||||
description: A List of distinct values for the column
|
||||
@@ -136,6 +147,10 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
# Element-level operators (Contains any / Contains all) request the
|
||||
# distinct array *elements* rather than distinct whole arrays.
|
||||
array_elements = parse_boolean_string(request.args.get("array_elements"))
|
||||
# Server-side search. Without it the client can only match against the
|
||||
# bounded first page, so a value beyond ``FILTER_SELECT_ROW_LIMIT`` is
|
||||
# unfindable on a high-cardinality column.
|
||||
search = (request.args.get("q") or "").strip() or None
|
||||
|
||||
# Cache distinct column-value results so a dashboard with many filters
|
||||
# backed by the same (often heavy) virtual dataset doesn't re-execute
|
||||
@@ -169,6 +184,7 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
"limit": row_limit,
|
||||
"denorm": denormalize_column,
|
||||
"elements": array_elements,
|
||||
"q": search,
|
||||
"rls": security_manager.get_rls_cache_key(datasource),
|
||||
"changed_on": str(getattr(datasource, "changed_on", "")),
|
||||
},
|
||||
@@ -184,7 +200,7 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
logger.debug(
|
||||
"column-values cache HIT: uid=%s col=%s", datasource.uid, column_name
|
||||
)
|
||||
response = self.response(200, result=cached)
|
||||
response = self.response(200, result=cached, limit=row_limit)
|
||||
response.headers["X-Cache-Status"] = "HIT"
|
||||
return response
|
||||
|
||||
@@ -194,6 +210,7 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
limit=row_limit,
|
||||
denormalize_column=denormalize_column,
|
||||
array_elements=array_elements,
|
||||
search=search,
|
||||
)
|
||||
except KeyError:
|
||||
return self.response(
|
||||
@@ -225,11 +242,15 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
timeout = datasource.cache_timeout or app.config.get(
|
||||
"CACHE_DEFAULT_TIMEOUT", 300
|
||||
)
|
||||
if search:
|
||||
# Every distinct search term is its own key, so a few users typing
|
||||
# would otherwise pin one entry per keystroke for the full timeout.
|
||||
timeout = min(timeout, SEARCH_CACHE_TIMEOUT)
|
||||
cache_manager.data_cache.set(cache_key, payload, timeout=timeout)
|
||||
logger.debug(
|
||||
"column-values cache MISS: uid=%s col=%s", datasource.uid, column_name
|
||||
)
|
||||
response = self.response(200, result=payload)
|
||||
response = self.response(200, result=payload, limit=row_limit)
|
||||
response.headers["X-Cache-Status"] = "MISS"
|
||||
return response
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from collections import defaultdict, deque
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from textwrap import dedent
|
||||
from typing import Any, cast, Optional, TYPE_CHECKING
|
||||
from typing import Any, Callable, cast, Optional, TYPE_CHECKING
|
||||
from urllib import parse
|
||||
|
||||
import pandas as pd
|
||||
@@ -182,6 +182,15 @@ class PrestoBaseEngineSpec(BaseEngineSpec, metaclass=ABCMeta):
|
||||
# which raises a query error. Use = true/false instead.
|
||||
use_equality_for_boolean_filters = True
|
||||
|
||||
# Presto/Trino's coordinator sends query results as JSON, which has no
|
||||
# literal for NaN/Infinity/-Infinity, so REAL/DOUBLE columns holding
|
||||
# those values arrive as quoted strings. Coerce them back to real
|
||||
# floats so numeric post-processing (e.g. a pivot's mean) doesn't choke
|
||||
# on a string value.
|
||||
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
|
||||
types.FLOAT: lambda val: float(val) if isinstance(val, str) else val
|
||||
}
|
||||
|
||||
column_type_mappings = (
|
||||
(
|
||||
re.compile(r"^boolean.*", re.IGNORECASE),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user