mirror of
https://github.com/apache/superset.git
synced 2026-08-29 03:21:14 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7cc22f4fe | ||
|
|
058eaf78ab | ||
|
|
30402b412c | ||
|
|
782a57fbfc | ||
|
|
99a1f756cd | ||
|
|
0eda633b45 | ||
|
|
5c24f72d92 | ||
|
|
60479fb958 | ||
|
|
4ced5ca35a | ||
|
|
fc26991cd4 | ||
|
|
a5c68c8df9 | ||
|
|
53e76afd70 | ||
|
|
d997d363e3 | ||
|
|
9a6f6ee0c0 | ||
|
|
94dd3d049c | ||
|
|
b3f718da62 | ||
|
|
abf338d611 | ||
|
|
e518b21994 | ||
|
|
e18f27e1ce | ||
|
|
9bf457dea6 | ||
|
|
cb7b790733 | ||
|
|
8bec85158c | ||
|
|
a30e4a4350 | ||
|
|
933dbbc2a2 | ||
|
|
a59b96c4f5 | ||
|
|
12cd259c55 | ||
|
|
3ddc3b1d56 | ||
|
|
98ec6018df | ||
|
|
2ebd415b8a | ||
|
|
81b3e85522 | ||
|
|
fd64efd72d | ||
|
|
e39bfb255b | ||
|
|
b7301ac88a | ||
|
|
fa59b44cfe |
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -78,6 +78,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -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
@@ -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]
|
||||
@@ -196,7 +196,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.7,<4.0",
|
||||
"mcp>=1.29.1,<2.0",
|
||||
"mcp>=1.29.1,<3.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
+17
-17
@@ -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",
|
||||
@@ -108,7 +108,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -234,7 +234,7 @@
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
|
||||
"eslint-plugin-storybook": "10.5.10",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -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",
|
||||
@@ -20208,9 +20208,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.1.tgz",
|
||||
"integrity": "sha512-oOhQTYhor88Xp8RVytq25tvBfiAjU0r9SCDC51Qop+3Wg5BR1xGMAkM+/dV4MZbcMhdaU1L9bkv6LC95JmiTig==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.2.tgz",
|
||||
"integrity": "sha512-HgYol2zhH3KbnW9Q4FY/FcIINbsYVL6rwQESChqBC2s9SLWtw1wg05+J7SCrr3BfQpNY2ReYhf8xjpd2JhKJOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28488,9 +28488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
|
||||
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz",
|
||||
"integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -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"
|
||||
},
|
||||
@@ -43490,7 +43490,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
|
||||
@@ -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",
|
||||
@@ -185,7 +185,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -311,7 +311,7 @@
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
|
||||
"eslint-plugin-storybook": "10.5.10",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -392,7 +392,7 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"dompurify": "^3.4.13",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint-plugin-import": {
|
||||
"eslint": "$eslint"
|
||||
@@ -416,7 +416,7 @@
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nanoid@>=3 <4": "3.3.18",
|
||||
"nwsapi": "^2.2.13",
|
||||
"nwsapi": "^2.2.24",
|
||||
"puppeteer": "^22.4.1",
|
||||
"tar": "^7.5.16",
|
||||
"typescript-json-schema": "^0.68.0",
|
||||
|
||||
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
|
||||
| 'onOpenChange'
|
||||
| 'optionRender'
|
||||
| 'placeholder'
|
||||
| 'prefix'
|
||||
| 'showArrow'
|
||||
| 'showSearch'
|
||||
| 'tokenSeparators'
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -119,6 +119,20 @@ export function retrieveErrorMessage(
|
||||
return statusError || parseStringResponse(str);
|
||||
}
|
||||
|
||||
function getFirstValidationError(message: JsonObject): string | undefined {
|
||||
const [firstError] = Object.values(message);
|
||||
|
||||
if (typeof firstError === 'string') {
|
||||
return firstError;
|
||||
}
|
||||
|
||||
if (Array.isArray(firstError)) {
|
||||
return firstError.find((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
|
||||
let error = { ...responseJson };
|
||||
// Backwards compatibility for old error renderers with the new error object
|
||||
@@ -126,13 +140,12 @@ export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
|
||||
error.error = error.description = error.errors[0].message;
|
||||
error.link = error.errors[0]?.extra?.link;
|
||||
}
|
||||
// Marshmallow field validation returns the error message in the format
|
||||
// of { message: { field1: [msg1, msg2], field2: [msg], } }
|
||||
// Marshmallow field validation returns arrays for string messages, but
|
||||
// serializes lazy translation messages as strings instead.
|
||||
if (!error.error && error.message) {
|
||||
if (typeof error.message === 'object') {
|
||||
error.error =
|
||||
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
|
||||
t('Invalid input');
|
||||
getFirstValidationError(error.message) || t('Invalid input');
|
||||
}
|
||||
if (typeof error.message === 'string') {
|
||||
if (checkForHtml(error.message)) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -244,6 +244,24 @@ test('parseErrorJson with message', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parseErrorJson preserves string-valued validation messages', () => {
|
||||
const calculatedColumnError =
|
||||
'Custom SQL fields cannot be parsed as a single SQL statement.';
|
||||
|
||||
expect(
|
||||
parseErrorJson({
|
||||
message: {
|
||||
'columns.0.expression': calculatedColumnError,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
message: {
|
||||
'columns.0.expression': calculatedColumnError,
|
||||
},
|
||||
error: calculatedColumnError,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseErrorJson with HTML message', () => {
|
||||
expect(
|
||||
parseErrorJson({
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -184,6 +184,8 @@ export default function transformProps(
|
||||
opacityB,
|
||||
minorSplitLine,
|
||||
minorTicks,
|
||||
gridlines,
|
||||
axisTicks,
|
||||
seriesType,
|
||||
seriesTypeB,
|
||||
showLegend,
|
||||
@@ -788,6 +790,8 @@ export default function transformProps(
|
||||
}),
|
||||
},
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
...(gridlines ? {} : { splitLine: { show: false } }),
|
||||
minInterval:
|
||||
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
|
||||
? (TIMEGRAIN_TO_TIMESTAMP[
|
||||
@@ -818,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(
|
||||
@@ -840,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,
|
||||
|
||||
@@ -283,6 +283,8 @@ export default function transformProps(
|
||||
metrics,
|
||||
minorSplitLine,
|
||||
minorTicks,
|
||||
gridlines,
|
||||
axisTicks,
|
||||
onlyTotal,
|
||||
opacity,
|
||||
orientation,
|
||||
@@ -1280,6 +1282,8 @@ export default function transformProps(
|
||||
}),
|
||||
},
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
...(gridlines ? {} : { splitLine: { show: false } }),
|
||||
minInterval:
|
||||
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
|
||||
? (TIMEGRAIN_TO_TIMESTAMP[
|
||||
@@ -1324,7 +1328,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,
|
||||
@@ -1338,7 +1342,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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -47,6 +47,9 @@ const getCrossFilterDataMask =
|
||||
) =>
|
||||
(value: string) => {
|
||||
const selected = Object.values(selectedValues);
|
||||
if (!labelMap[value] && !selected.includes(value)) {
|
||||
return undefined;
|
||||
}
|
||||
let values: string[];
|
||||
if (selected.includes(value)) {
|
||||
values = selected.filter(v => v !== value);
|
||||
|
||||
+55
@@ -116,6 +116,8 @@ const formData: EchartsMixedTimeseriesFormData = {
|
||||
markerSizeB: 0,
|
||||
minorSplitLine: false,
|
||||
minorTicks: false,
|
||||
gridlines: true,
|
||||
axisTicks: true,
|
||||
opacity: 0,
|
||||
opacityB: 0,
|
||||
orderDesc: false,
|
||||
@@ -1509,3 +1511,56 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
|
||||
expect(html).not.toContain(longSeriesName);
|
||||
});
|
||||
});
|
||||
|
||||
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('draws gridlines and axis ticks when both are enabled', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({});
|
||||
|
||||
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('hides the gridlines on the primary axis', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({ gridlines: false });
|
||||
|
||||
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('never turns the secondary axis gridlines on', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({ gridlines: true });
|
||||
|
||||
expect(yAxis[0].splitLine.show).toBe(true);
|
||||
expect(yAxis[1].splitLine.show).toBe(false);
|
||||
expect(xAxis.splitLine).toBeUndefined();
|
||||
});
|
||||
|
||||
test('hides the ticks on the x axis and both y axes', () => {
|
||||
const { xAxis, yAxis } = transformWithChrome({ axisTicks: false });
|
||||
|
||||
expect(xAxis.axisTick.show).toBe(false);
|
||||
expect(yAxis[0].axisTick.show).toBe(false);
|
||||
expect(yAxis[1].axisTick.show).toBe(false);
|
||||
});
|
||||
|
||||
@@ -2766,3 +2766,76 @@ 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);
|
||||
});
|
||||
|
||||
@@ -180,3 +180,46 @@ test('cross-filter does nothing when emitCrossFilters is false', () => {
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cross-filter does nothing when name is missing from labelMap', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const props = buildProps({
|
||||
groupby: ['topics'],
|
||||
labelMap: {
|
||||
cancellations: ['cancellations'],
|
||||
},
|
||||
selectedValues: {},
|
||||
setDataMask,
|
||||
});
|
||||
|
||||
const handlers = allEventHandlers(props);
|
||||
// e.g. Pie "Other" category is not present in labelMap
|
||||
handlers.click({ name: 'Other' });
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cross-filter still deselects a previously selected value that is missing from labelMap', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const props = buildProps({
|
||||
groupby: ['topics'],
|
||||
labelMap: {
|
||||
cancellations: ['cancellations'],
|
||||
},
|
||||
// "Other" was selected before it dropped out of labelMap (e.g. a stale
|
||||
// cross-filter from an earlier render or dashboard state).
|
||||
selectedValues: { 0: 'Other' },
|
||||
setDataMask,
|
||||
});
|
||||
|
||||
const handlers = allEventHandlers(props);
|
||||
handlers.click({ name: 'Other' });
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
|
||||
@@ -355,25 +355,31 @@ describe('SqlEditor', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// findByRole('button', { name }) walks every stylesheet rule via nwsapi to
|
||||
// compute the accessible name, which can crash on an unrelated antd Tabs
|
||||
// "more" button style; findByLabelText matches the same aria-label without
|
||||
// that traversal.
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
});
|
||||
expect(await findByLabelText('Save dataset')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the latest query failed', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Failed,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
expect(await findByLabelText('Save dataset')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the results are not loaded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
expect(await findByLabelText('Save dataset')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders an Extension if provided', async () => {
|
||||
|
||||
+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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,6 +53,22 @@ test('RowCountLabel renders limit with danger and tooltip', async () => {
|
||||
expect(tooltip).toHaveTextContent('The row limit');
|
||||
});
|
||||
|
||||
test('RowCountLabel uses a caller-provided limitReachedMessage instead of the default', async () => {
|
||||
render(
|
||||
<RowCountLabel
|
||||
rowcount={100}
|
||||
limit={100}
|
||||
limitReachedMessage="Custom limit message"
|
||||
/>,
|
||||
);
|
||||
const expectedText = '100 rows';
|
||||
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||
userEvent.hover(screen.getByText(expectedText));
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent('Custom limit message');
|
||||
expect(tooltip).not.toHaveTextContent('The row limit set for the chart');
|
||||
});
|
||||
|
||||
test('RowCountLabel renders loading', () => {
|
||||
render(<RowCountLabel loading />);
|
||||
const expectedText = 'Loading...';
|
||||
|
||||
@@ -26,6 +26,9 @@ type RowCountLabelProps = {
|
||||
limit?: number;
|
||||
loading?: boolean;
|
||||
label?: JSX.Element;
|
||||
// Overrides the default "chart" wording for panes (e.g. samples) where the
|
||||
// limit reached isn't the chart's own row_limit.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
};
|
||||
|
||||
const limitReachedMsg = t(
|
||||
@@ -33,7 +36,13 @@ const limitReachedMsg = t(
|
||||
);
|
||||
|
||||
export default function RowCountLabel(props: RowCountLabelProps) {
|
||||
const { rowcount = 0, limit = null, loading, label } = props;
|
||||
const {
|
||||
rowcount = 0,
|
||||
limit = null,
|
||||
loading,
|
||||
label,
|
||||
limitReachedMessage,
|
||||
} = props;
|
||||
const limitReached = limit && rowcount >= limit;
|
||||
const type =
|
||||
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
|
||||
@@ -50,7 +59,10 @@ export default function RowCountLabel(props: RowCountLabelProps) {
|
||||
</Label>
|
||||
);
|
||||
return limitReached ? (
|
||||
<Tooltip id="tt-rowcount-tooltip" title={<span>{limitReachedMsg}</span>}>
|
||||
<Tooltip
|
||||
id="tt-rowcount-tooltip"
|
||||
title={<span>{limitReachedMessage ?? limitReachedMsg}</span>}
|
||||
>
|
||||
{label || labelText}
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -117,7 +117,7 @@ const StyledDiv = styled.div`
|
||||
${
|
||||
isMobileConsumptionEnabled()
|
||||
? `@media (max-width: ${theme.screenSMMax}px) {
|
||||
[data-test='slice-header'] .header-title {
|
||||
.slice-header .header-title {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
white-space: normal;
|
||||
|
||||
@@ -79,6 +79,15 @@ type PropertiesModalProps = {
|
||||
addSuccessToast: (message: string) => void;
|
||||
addDangerToast: (message: string) => void;
|
||||
onlyApply?: boolean;
|
||||
renderExtraFields?: (context: {
|
||||
assetId: number;
|
||||
assetType: 'dashboard';
|
||||
accessorCount: number;
|
||||
}) => {
|
||||
content: React.ReactNode;
|
||||
saveDisabled?: boolean;
|
||||
saveTooltip?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DashboardInfo = {
|
||||
@@ -107,6 +116,7 @@ const PropertiesModal = ({
|
||||
onlyApply = false,
|
||||
onSubmit = () => {},
|
||||
show = false,
|
||||
renderExtraFields,
|
||||
}: PropertiesModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [form] = Form.useForm();
|
||||
@@ -123,6 +133,17 @@ const PropertiesModal = ({
|
||||
});
|
||||
const [editors, setEditors] = useState<Subject[]>([]);
|
||||
const [viewers, setViewers] = useState<Subject[]>([]);
|
||||
|
||||
const extraFields = useMemo(
|
||||
() =>
|
||||
renderExtraFields?.({
|
||||
assetId: dashboardId,
|
||||
assetType: 'dashboard',
|
||||
accessorCount: editors.length + viewers.length,
|
||||
}),
|
||||
[renderExtraFields, dashboardId, editors.length, viewers.length],
|
||||
);
|
||||
|
||||
const saveLabel = onlyApply ? t('Apply') : t('Save');
|
||||
const [tags, setTags] = useState<TagType[]>([]);
|
||||
const [customCss, setCustomCss] = useState('');
|
||||
@@ -698,15 +719,21 @@ const PropertiesModal = ({
|
||||
}}
|
||||
title={t('Dashboard properties')}
|
||||
isEditMode
|
||||
saveDisabled={dashboardInfo?.isManagedExternally || hasErrors}
|
||||
saveDisabled={
|
||||
dashboardInfo?.isManagedExternally ||
|
||||
hasErrors ||
|
||||
extraFields?.saveDisabled
|
||||
}
|
||||
saveLoading={isApplying}
|
||||
contentLoading={isLoading}
|
||||
errorTooltip={
|
||||
dashboardInfo?.isManagedExternally
|
||||
? t(
|
||||
"This dashboard is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
extraFields?.saveDisabled && extraFields?.saveTooltip
|
||||
? extraFields.saveTooltip
|
||||
: dashboardInfo?.isManagedExternally
|
||||
? t(
|
||||
"This dashboard is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
}
|
||||
saveText={saveLabel}
|
||||
wrapProps={{ 'data-test': 'properties-edit-modal' }}
|
||||
@@ -769,6 +796,7 @@ const PropertiesModal = ({
|
||||
onChangeViewers={handleOnChangeViewers}
|
||||
onChangeTags={handleChangeTags}
|
||||
onClearTags={handleClearTags}
|
||||
renderExtraFields={extraFields}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@ interface AccessSectionProps {
|
||||
onChangeViewers: (viewers: SubjectPickerValue[]) => void;
|
||||
onChangeTags: (tags: { label: string; value: number }[]) => void;
|
||||
onClearTags: () => void;
|
||||
renderExtraFields?: {
|
||||
content: React.ReactNode;
|
||||
saveDisabled?: boolean;
|
||||
saveTooltip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const AccessSection = ({
|
||||
@@ -49,6 +54,7 @@ const AccessSection = ({
|
||||
onChangeViewers,
|
||||
onChangeTags,
|
||||
onClearTags,
|
||||
renderExtraFields,
|
||||
}: AccessSectionProps) => {
|
||||
const enableViewers = isFeatureEnabled(FeatureFlag.EnableViewers);
|
||||
|
||||
@@ -134,6 +140,7 @@ const AccessSection = ({
|
||||
/>
|
||||
</ModalFormField>
|
||||
)}
|
||||
{renderExtraFields?.content}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -208,6 +208,18 @@ test('Should render', () => {
|
||||
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should expose a class hook, not just data-test, for fullscreen styling', () => {
|
||||
const props = createProps();
|
||||
render(<SliceHeader {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState,
|
||||
});
|
||||
// The production build strips data-test attributes, so CSS that targets the
|
||||
// header must hang off a class instead.
|
||||
expect(screen.getByTestId('slice-header')).toHaveClass('slice-header');
|
||||
});
|
||||
|
||||
test('Should render - default props', () => {
|
||||
const props = createProps();
|
||||
|
||||
|
||||
@@ -270,7 +270,11 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartHeaderStyles data-test="slice-header" ref={ref}>
|
||||
<ChartHeaderStyles
|
||||
className="slice-header"
|
||||
data-test="slice-header"
|
||||
ref={ref}
|
||||
>
|
||||
<div className="header-title" ref={headerRef}>
|
||||
<Tooltip title={headerTooltip}>
|
||||
{/* this div ensures the hover event triggers correctly and prevents flickering */}
|
||||
|
||||
+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();
|
||||
|
||||
|
||||
@@ -19,20 +19,13 @@
|
||||
import { css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
export const fullscreenStyles = (theme: SupersetTheme) => css`
|
||||
[data-test='dashboard-component-chart-holder']:fullscreen {
|
||||
.dashboard-component-chart-holder:fullscreen {
|
||||
background-color: ${theme.colorBgBase};
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
z-index: ${theme.zIndexPopupBase};
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
/* Ensure children take up available space */
|
||||
.dashboard-chart,
|
||||
@@ -58,13 +51,8 @@ export const fullscreenStyles = (theme: SupersetTheme) => css`
|
||||
}
|
||||
}
|
||||
|
||||
/* Interaction and Header fixes */
|
||||
[data-test='dashboard-component-chart-holder']:fullscreen * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-test='dashboard-component-chart-holder']:fullscreen
|
||||
[data-test='slice-header'] {
|
||||
/* Keep the header above the chart it shares the fullscreen layer with */
|
||||
.dashboard-component-chart-holder:fullscreen .slice-header {
|
||||
z-index: ${theme.zIndexPopupBase};
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-7
@@ -337,13 +337,17 @@ const ChartHolder = ({
|
||||
)}
|
||||
>
|
||||
<AntdThemeProvider
|
||||
getPopupContainer={(triggerNode: HTMLElement) =>
|
||||
document.fullscreenElement
|
||||
? (triggerNode?.closest?.(
|
||||
'[data-test="dashboard-component-chart-holder"]',
|
||||
) as HTMLElement) || document.body
|
||||
: document.body
|
||||
}
|
||||
getPopupContainer={(triggerNode?: HTMLElement) => {
|
||||
// Only the fullscreen element's subtree is painted, so popups
|
||||
// have to be portaled into it rather than to document.body.
|
||||
// Resolve it directly instead of matching a selector: the
|
||||
// production build strips data-test attributes.
|
||||
const fullscreenElement =
|
||||
document.fullscreenElement as HTMLElement | null;
|
||||
return triggerNode && fullscreenElement?.contains(triggerNode)
|
||||
? fullscreenElement
|
||||
: document.body;
|
||||
}}
|
||||
>
|
||||
{!editMode && (
|
||||
<AnchorLink
|
||||
|
||||
+26
@@ -97,3 +97,29 @@ test('does not render DeckglLayerVisibilityTooltip for standard filter type', ()
|
||||
screen.queryByTestId('deckgl-layer-visibility-tooltip-icon'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not mark a defaultToFirstItem-only filter as required', () => {
|
||||
render(
|
||||
<FilterControl
|
||||
filter={{
|
||||
...nativeFilter,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
}}
|
||||
onFilterSelectionChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('marks an enableEmptyFilter filter as required', () => {
|
||||
render(
|
||||
<FilterControl
|
||||
filter={{
|
||||
...nativeFilter,
|
||||
controlValues: { enableEmptyFilter: true },
|
||||
}}
|
||||
onFilterSelectionChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
+1
-3
@@ -65,9 +65,7 @@ const FilterControl = ({
|
||||
isFilterInScope(filter) &&
|
||||
checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
|
||||
const validateStatus = isMissingRequiredValue ? 'error' : undefined;
|
||||
const isRequired =
|
||||
!!filter.controlValues?.enableEmptyFilter ||
|
||||
!!filter.controlValues?.defaultToFirstItem;
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
const inverseSelection = !!filter.controlValues?.inverseSelection;
|
||||
|
||||
const {
|
||||
|
||||
@@ -211,6 +211,16 @@ test('checkIsMissingRequiredValue returns false for non-required filter with und
|
||||
expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false);
|
||||
});
|
||||
|
||||
test('checkIsMissingRequiredValue returns false when only defaultToFirstItem is set', () => {
|
||||
const filter = createFilter('test-filter', {
|
||||
enableEmptyFilter: false,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
});
|
||||
|
||||
expect(checkIsMissingRequiredValue(filter, { value: null })).toBe(false);
|
||||
expect(checkIsMissingRequiredValue(filter, { value: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
test('checkIsMissingRequiredValue returns falsy for filter without controlValues', () => {
|
||||
const filter = { id: 'test-filter' } as Filter;
|
||||
const filterState: FilterState = { value: undefined };
|
||||
@@ -299,6 +309,48 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
|
||||
);
|
||||
});
|
||||
|
||||
test('checkIsApplyDisabled enables Apply after clearing a cascading defaultToFirstItem child', () => {
|
||||
// Regression: a child filter that is dependent on a parent and configured with
|
||||
// "Select first filter value by default" but NOT "Filter value is required"
|
||||
// must stay clearable — clearing it may not disable Apply.
|
||||
const parent = createFilter('parent', {
|
||||
enableEmptyFilter: true,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
});
|
||||
const child = createFilter('child', {
|
||||
enableEmptyFilter: false,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
});
|
||||
const dataMaskSelected: DataMaskStateWithId = {
|
||||
parent: {
|
||||
id: 'parent',
|
||||
filterState: { value: ['USA'] },
|
||||
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
|
||||
},
|
||||
child: {
|
||||
id: 'child',
|
||||
filterState: { value: null },
|
||||
extraFormData: {},
|
||||
},
|
||||
};
|
||||
const dataMaskApplied: DataMaskStateWithId = {
|
||||
parent: {
|
||||
id: 'parent',
|
||||
filterState: { value: ['USA'] },
|
||||
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
|
||||
},
|
||||
child: {
|
||||
id: 'child',
|
||||
filterState: { value: ['CA'] },
|
||||
extraFormData: createExtraFormDataWithFilter('state', ['CA']),
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, [parent, child]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
|
||||
// Regression: when a required filter's default isn't applied (Applied missing
|
||||
// the entry) and the user types a value, Selected gains an entry Applied
|
||||
|
||||
@@ -48,9 +48,10 @@ export const checkIsMissingRequiredValue = (
|
||||
filter: FilterElement,
|
||||
filterState?: FilterState,
|
||||
) => {
|
||||
const isRequired =
|
||||
!!filter.controlValues?.enableEmptyFilter ||
|
||||
!!filter.controlValues?.defaultToFirstItem;
|
||||
// Only `enableEmptyFilter` ("Filter value is required") makes a value
|
||||
// mandatory. `defaultToFirstItem` merely seeds an initial selection, so a
|
||||
// filter cleared by the user must stay clearable, with Apply enabled.
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
|
||||
if (!isRequired) return false;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
userEvent,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import Control, { ControlProps } from 'src/explore/components/Control';
|
||||
|
||||
const defaultProps: ControlProps = {
|
||||
@@ -77,3 +83,72 @@ test('call setControlValue if isVisible is false', async () => {
|
||||
expect(defaultProps.actions.setControlValue).toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the description icon while the control is hovered', async () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.hover(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.unhover(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the description icon while the control has keyboard focus', () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.focus(screen.getByRole('checkbox'));
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
expect(infoIcon).toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(screen.getByRole('checkbox'), { relatedTarget: infoIcon });
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(infoIcon, { relatedTarget: document.body });
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the description icon visible when the pointer leaves a focused control', () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.focus(screen.getByRole('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useCallback, useState, useEffect } from 'react';
|
||||
import { ReactNode, useCallback, useState, useEffect, FocusEvent } from 'react';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import {
|
||||
ControlType,
|
||||
@@ -70,7 +70,18 @@ export default function Control(props: ControlProps) {
|
||||
} = props;
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const wasVisible = usePrevious(isVisible);
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
setFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: any, errors: any[]) => setControlValue(name, value, errors),
|
||||
[name, setControlValue],
|
||||
@@ -119,9 +130,15 @@ export default function Control(props: ControlProps) {
|
||||
style={hidden ? { display: 'none' } : undefined}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<ControlComponent onChange={onChange} hovered={hovered} {...props} />
|
||||
<ControlComponent
|
||||
onChange={onChange}
|
||||
hovered={hovered || focused}
|
||||
{...props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</StyledControl>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import ControlHeader from './ControlHeader';
|
||||
|
||||
const description = 'This control filters the whole chart.';
|
||||
|
||||
test('does not render the description icon until the control is hovered', () => {
|
||||
const { rerender } = render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('notifies onDescriptionHoverChange when the info icon is hovered', async () => {
|
||||
const onDescriptionHoverChange = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
onDescriptionHoverChange={onDescriptionHoverChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
await userEvent.hover(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
|
||||
|
||||
await userEvent.unhover(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('notifies onDescriptionHoverChange when the info icon is focused', () => {
|
||||
const onDescriptionHoverChange = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
onDescriptionHoverChange={onDescriptionHoverChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
fireEvent.focus(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
|
||||
|
||||
fireEvent.blur(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('activates tooltipOnClick from the keyboard', () => {
|
||||
const tooltipOnClick = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
tooltipOnClick={tooltipOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Show info tooltip' }), {
|
||||
key: 'Enter',
|
||||
});
|
||||
expect(tooltipOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export type ControlHeaderProps = {
|
||||
tooltipOnClick?: () => void;
|
||||
warning?: string;
|
||||
danger?: string;
|
||||
onDescriptionHoverChange?: (hovered: boolean) => void;
|
||||
// Allow extra props from control spread patterns (e.g. {...this.props})
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -71,6 +72,7 @@ const ControlHeader: FC<ControlHeaderProps> = ({
|
||||
tooltipOnClick = () => {},
|
||||
warning,
|
||||
danger,
|
||||
onDescriptionHoverChange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -89,24 +91,44 @@ const ControlHeader: FC<ControlHeaderProps> = ({
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
padding-left: ${theme.sizeUnit}px;
|
||||
transform: translate(100%, -50%);
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
`}
|
||||
>
|
||||
{description && (
|
||||
<span>
|
||||
<>
|
||||
<Tooltip
|
||||
id="description-tooltip"
|
||||
title={description}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<Icons.InfoCircleOutlined
|
||||
css={iconStyles}
|
||||
{/* Same role="button" pattern as the label text: a real <button>
|
||||
is not valid inside FormLabel's <label>. */}
|
||||
<span
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-test={`${name}-description-icon`}
|
||||
aria-label={t('Show info tooltip')}
|
||||
onMouseEnter={() => onDescriptionHoverChange?.(true)}
|
||||
onMouseLeave={() => onDescriptionHoverChange?.(false)}
|
||||
onFocus={() => onDescriptionHoverChange?.(true)}
|
||||
onBlur={() => onDescriptionHoverChange?.(false)}
|
||||
onClick={tooltipOnClick}
|
||||
/>
|
||||
onKeyDown={handleKeyboardActivation(tooltipOnClick)}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
`}
|
||||
>
|
||||
<Icons.InfoCircleOutlined css={iconStyles} />
|
||||
</span>
|
||||
</Tooltip>{' '}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{renderTrigger && (
|
||||
<span>
|
||||
|
||||
+11
-4
@@ -68,6 +68,8 @@ export const TableControls = ({
|
||||
canDownload,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
limitReachedMessage,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -111,14 +113,19 @@ export const TableControls = ({
|
||||
value={rowLimit}
|
||||
onChange={onRowLimitChange}
|
||||
options={rowLimitOptions ?? []}
|
||||
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
|
||||
prefix={t('Limit')}
|
||||
css={css`
|
||||
min-width: 110px;
|
||||
min-width: 160px;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
)}
|
||||
<RowCountLabel
|
||||
rowcount={rowcount}
|
||||
limit={effectiveRowLimit ?? rowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{canDownload && onDownloadCSV && onDownloadXLSX && (
|
||||
<DownloadDropdown
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
|
||||
@@ -136,6 +136,12 @@ export const SamplesPane = ({
|
||||
|
||||
const columns = useGridColumns(colnames, coltypes, data);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
// Samples aren't capped by a chart's row_limit, just this pane's own
|
||||
// page-size selector, so RowCountLabel's default "chart" wording is wrong here.
|
||||
const limitReachedMessage = t(
|
||||
'The sample row limit was reached. This %s may contain more rows.',
|
||||
datasetLabelLower(),
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -161,6 +167,7 @@ export const SamplesPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<ErrorAlertWrapper>
|
||||
@@ -197,6 +204,7 @@ export const SamplesPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<GridContainer>
|
||||
|
||||
+4
@@ -56,6 +56,8 @@ export const SingleQueryResultPane = ({
|
||||
columnDisplayNames,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
limitReachedMessage,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -86,6 +88,8 @@ export const SingleQueryResultPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={rowLimitOptions}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={onRowLimitChange}
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
onDownloadXLSX={onDownloadXLSX}
|
||||
|
||||
@@ -84,6 +84,17 @@ export const useResultsPane = ({
|
||||
// Never exceed the chart's own row_limit
|
||||
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
|
||||
|
||||
// When this pane's own row-limit selector is stricter than the chart's
|
||||
// row_limit, it - not the chart - is what caps the result, so
|
||||
// RowCountLabel's default "chart" wording would be misleading (the chart's
|
||||
// configured row_limit was never actually reached).
|
||||
const limitReachedMessage =
|
||||
rowLimit < chartRowLimit
|
||||
? t(
|
||||
'The row limit selected for this pane was reached. There may be more matching rows.',
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const cappedFormData = useMemo(
|
||||
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
|
||||
[queryFormData, effectiveRowLimit],
|
||||
@@ -236,6 +247,8 @@ export const useResultsPane = ({
|
||||
columnDisplayNames={columnDisplayNames}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
</StyledDiv>
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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 {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
sleep,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
TableControls,
|
||||
ROW_LIMIT_OPTIONS,
|
||||
} from '../components/DataTableControls';
|
||||
import { TableControlsProps } from '../types';
|
||||
|
||||
const setup = (overrides: Partial<TableControlsProps> = {}) =>
|
||||
render(
|
||||
<TableControls
|
||||
data={[]}
|
||||
columnNames={['name']}
|
||||
columnTypes={[GenericDataType.String]}
|
||||
rowcount={0}
|
||||
onInputChange={jest.fn()}
|
||||
isLoading={false}
|
||||
canDownload
|
||||
rowLimit={100}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={jest.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
test('shows the row count when the result fills the selected row limit', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
|
||||
});
|
||||
|
||||
test('warns that the row limit was reached when the result fills it', async () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not warn when the result is smaller than the selected row limit', async () => {
|
||||
setup({ rowcount: 42, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
// Wait past antd's 0.1s mouseEnterDelay so a regression that made the
|
||||
// tooltip appear would be caught here instead of racing the delay.
|
||||
await act(() => sleep(150));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
|
||||
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('labels the row limit selector so it is not read as a second row count', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByText('Limit')).toBeInTheDocument();
|
||||
});
|
||||
+42
-1
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { screen, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
screen,
|
||||
render,
|
||||
waitFor,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneOnDashboard } from '../components';
|
||||
@@ -157,6 +162,42 @@ describe('useResultsPane query data reuse', () => {
|
||||
expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('2 rows')).toBeVisible();
|
||||
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
|
||||
|
||||
userEvent.hover(screen.getByText('2 rows'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test("warns about this pane's own row limit, not the chart's, when the pane's selector is what caps the result", async () => {
|
||||
// chart row_limit (2000) is well above this pane's default 1000-row
|
||||
// selector, so the selector - not the chart - is what truncates here.
|
||||
const props = createResultsPaneOnDashboardProps({
|
||||
sliceId: 208,
|
||||
rowLimit: 2000,
|
||||
queriesResponse: [
|
||||
{
|
||||
colnames: ['genre'],
|
||||
coltypes: [1],
|
||||
data: Array.from({ length: 1500 }, (_, i) => ({
|
||||
genre: `genre-${i}`,
|
||||
})),
|
||||
rowcount: 1500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
|
||||
|
||||
const rowCountLabel = await screen.findByTestId('row-count-label');
|
||||
expect(rowCountLabel).toHaveTextContent('1k rows');
|
||||
|
||||
userEvent.hover(rowCountLabel);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent(
|
||||
'The row limit selected for this pane was reached',
|
||||
);
|
||||
expect(tooltip).not.toHaveTextContent('for the chart');
|
||||
});
|
||||
|
||||
test('renders an empty (0 rows) result from reused data without an API call', async () => {
|
||||
|
||||
@@ -84,6 +84,12 @@ export interface TableControlsProps extends DrillControlsProps {
|
||||
canDownload: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
// Effective result limit, capped by the chart's row limit.
|
||||
// Defaults to `rowLimit` and controls the "row limit reached" warning.
|
||||
effectiveRowLimit?: number;
|
||||
// Overrides RowCountLabel's default "chart" wording for panes (e.g.
|
||||
// samples) where the limit reached isn't the chart's own row_limit.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -104,5 +110,9 @@ export interface SingleQueryResultPaneProp
|
||||
columnDisplayNames?: Record<string, string>;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
effectiveRowLimit?: number;
|
||||
// Overrides RowCountLabel's default "chart" wording when the pane's own
|
||||
// row-limit selector, not the chart's row_limit, is what capped the result.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChangeEvent, useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
ChangeEvent,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
Input,
|
||||
@@ -56,6 +63,12 @@ export type PropertiesModalProps = {
|
||||
permissionsError?: string;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
/** Optional render prop for injecting extra fields (e.g. folder selector). */
|
||||
renderExtraFields?: (context: {
|
||||
assetId: number;
|
||||
assetType: 'chart';
|
||||
accessorCount: number;
|
||||
}) => { content: ReactNode; saveDisabled?: boolean; saveTooltip?: string };
|
||||
};
|
||||
|
||||
function PropertiesModal({
|
||||
@@ -65,6 +78,7 @@ function PropertiesModal({
|
||||
show,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
renderExtraFields,
|
||||
}: PropertiesModalProps) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// values of form inputs
|
||||
@@ -87,6 +101,25 @@ function PropertiesModal({
|
||||
>(null);
|
||||
const [tags, setTags] = useState<TagType[]>([]);
|
||||
|
||||
const chartId = slice.slice_id;
|
||||
const extraFields = useMemo(
|
||||
() =>
|
||||
chartId
|
||||
? renderExtraFields?.({
|
||||
assetId: chartId,
|
||||
assetType: 'chart',
|
||||
accessorCount:
|
||||
(selectedEditors?.length ?? 0) + (selectedViewers?.length ?? 0),
|
||||
})
|
||||
: undefined,
|
||||
[
|
||||
chartId,
|
||||
renderExtraFields,
|
||||
selectedEditors?.length,
|
||||
selectedViewers?.length,
|
||||
],
|
||||
);
|
||||
|
||||
// Validation setup
|
||||
const modalSections = useMemo(
|
||||
() => [
|
||||
@@ -281,14 +314,20 @@ function PropertiesModal({
|
||||
title={t('Chart properties')}
|
||||
isEditMode
|
||||
saveDisabled={
|
||||
submitting || !name || slice.is_managed_externally || hasErrors
|
||||
submitting ||
|
||||
!name ||
|
||||
slice.is_managed_externally ||
|
||||
hasErrors ||
|
||||
extraFields?.saveDisabled
|
||||
}
|
||||
errorTooltip={
|
||||
slice.is_managed_externally
|
||||
? t(
|
||||
"This chart is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
extraFields?.saveDisabled && extraFields?.saveTooltip
|
||||
? extraFields.saveTooltip
|
||||
: slice.is_managed_externally
|
||||
? t(
|
||||
"This chart is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
}
|
||||
wrapProps={{ 'data-test': 'properties-edit-modal' }}
|
||||
>
|
||||
@@ -395,6 +434,7 @@ function PropertiesModal({
|
||||
/>
|
||||
</ModalFormField>
|
||||
)}
|
||||
{extraFields?.content}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+18
-2
@@ -147,6 +147,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
onOpenPopover = noOp,
|
||||
onClosePopover = noOp,
|
||||
isOverflowingFilterBar = false,
|
||||
hovered: isControlHovered = false,
|
||||
} = props;
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
|
||||
@@ -161,9 +162,16 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
|
||||
const [evalResponse, setEvalResponse] = useState<string>(value);
|
||||
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(t(value));
|
||||
const [isDescriptionHovered, setIsDescriptionHovered] = useState(false);
|
||||
const theme = useTheme();
|
||||
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlHovered) {
|
||||
setIsDescriptionHovered(false);
|
||||
}
|
||||
}, [isControlHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === NO_TIME_RANGE) {
|
||||
setActualTimeRange(NO_TIME_RANGE);
|
||||
@@ -368,7 +376,12 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
}
|
||||
overlayClassName="time-range-popover"
|
||||
>
|
||||
<Tooltip placement="top" title={tooltipTitle}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isDescriptionHovered ? null : tooltipTitle}
|
||||
mouseLeaveDelay={0}
|
||||
overlayStyle={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Wrap in a span so the Popover gets a stable DOM ref target;
|
||||
DateLabel forwards its ref to an inner span used for measuring
|
||||
text truncation, which would otherwise become the popover's
|
||||
@@ -390,7 +403,10 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlHeader {...props} />
|
||||
<ControlHeader
|
||||
{...props}
|
||||
onDescriptionHoverChange={setIsDescriptionHovered}
|
||||
/>
|
||||
{popoverContent}
|
||||
</>
|
||||
);
|
||||
|
||||
+86
-4
@@ -18,16 +18,35 @@
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import { NO_TIME_RANGE, fetchTimeRange } from '@superset-ui/core';
|
||||
import DateFilterLabel from '..';
|
||||
import { DateFilterControlProps } from '../types';
|
||||
import { DateFilterTestKey } from '../utils';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
fetchTimeRange: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedFetchTimeRange = fetchTimeRange as jest.MockedFunction<
|
||||
typeof fetchTimeRange
|
||||
>;
|
||||
|
||||
const FIELD_TOOLTIP = '2024-01-01 ≤ col < 2024-01-08';
|
||||
const DESCRIPTION_TOOLTIP =
|
||||
'This control filters the whole chart based on the selected time range.';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
const defaultProps = {
|
||||
onChange: jest.fn(),
|
||||
@@ -35,6 +54,11 @@ const defaultProps = {
|
||||
onOpenPopover: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetchTimeRange.mockReset();
|
||||
mockedFetchTimeRange.mockResolvedValue({ value: FIELD_TOOLTIP });
|
||||
});
|
||||
|
||||
function setup(
|
||||
props: Omit<DateFilterControlProps, 'name'> = defaultProps,
|
||||
store: any = mockStore({}),
|
||||
@@ -136,3 +160,61 @@ test('DateFilter should properly handle isOverflowingFilterBar prop changes', ()
|
||||
expect(popoverAfterRerender?.parentElement).toBe(trigger.parentElement);
|
||||
expect(popoverAfterRerender?.parentElement).not.toBe(document.body);
|
||||
});
|
||||
|
||||
test('hovering the description icon does not show the date range tooltip', async () => {
|
||||
const tooltipOnClick = jest.fn();
|
||||
render(
|
||||
setup({
|
||||
...defaultProps,
|
||||
value: 'Last week',
|
||||
label: 'Date Range',
|
||||
description: DESCRIPTION_TOOLTIP,
|
||||
hovered: true,
|
||||
tooltipOnClick,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Last week')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(screen.getByText('Last week'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
|
||||
|
||||
const descriptionIcon = screen.getByRole('button', {
|
||||
name: 'Show info tooltip',
|
||||
});
|
||||
fireEvent.focus(descriptionIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(DESCRIPTION_TOOLTIP);
|
||||
expect(screen.getByRole('tooltip')).not.toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
});
|
||||
|
||||
fireEvent.blur(descriptionIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
});
|
||||
|
||||
await userEvent.unhover(screen.getByText('Last week'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(descriptionIcon);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent(DESCRIPTION_TOOLTIP);
|
||||
expect(tooltip).not.toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
|
||||
await userEvent.unhover(descriptionIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(descriptionIcon, { key: 'Enter' });
|
||||
expect(tooltipOnClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type SelectOptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -113,4 +115,8 @@ export interface DateFilterControlProps {
|
||||
onOpenPopover?: () => void;
|
||||
onClosePopover?: () => void;
|
||||
isOverflowingFilterBar?: boolean;
|
||||
hovered?: boolean;
|
||||
description?: ReactNode;
|
||||
label?: ReactNode;
|
||||
tooltipOnClick?: () => void;
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
AppSection,
|
||||
Behavior,
|
||||
ChartProps,
|
||||
type DataMask,
|
||||
type FilterState,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
@@ -877,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1393,6 +1426,153 @@ test('preserves dependent filter value restored from URL when it exists in data'
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps a dependent filter empty after the user clears it', async () => {
|
||||
// Regression: a dependent filter with "Select first filter value by default"
|
||||
// used to re-apply the first option as soon as the cleared value round-tripped
|
||||
// through the filter bar, making it impossible to clear.
|
||||
jest.useRealTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: true,
|
||||
// Non-empty extraFormData is what marks this filter as dependent
|
||||
extraFormData: {
|
||||
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// The filter bar feeds every dispatched dataMask back into the plugin as the
|
||||
// controlled `filterState` prop; the harness reproduces that round-trip.
|
||||
const ControlledSelectFilter = () => {
|
||||
const [filterState, setFilterState] = useState<FilterState>({
|
||||
value: ['boy'],
|
||||
});
|
||||
const handleDataMask = useCallback((dataMask: DataMask) => {
|
||||
setDataMaskMock(dataMask);
|
||||
setFilterState(prev => ({ ...prev, ...dataMask.filterState }));
|
||||
}, []);
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<SelectFilterPlugin
|
||||
// @ts-expect-error
|
||||
{...transformProps({ ...testProps, filterState })}
|
||||
setDataMask={handleDataMask}
|
||||
showOverflow={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<ControlledSelectFilter />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: { value: ['boy'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
userEvent.click(
|
||||
screen.getByRole('img', {
|
||||
name: /close-circle/i,
|
||||
hidden: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setDataMaskMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {},
|
||||
filterState: expect.objectContaining({ value: null }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Let the re-validation effects settle: the value must not come back
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(setDataMaskMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filterState: expect.objectContaining({ value: null }),
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByTitle('boy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps a dependent filter empty when it mounts with a cleared value', async () => {
|
||||
// Regression: after a reload the cleared state comes back as `value: null` on
|
||||
// a fresh component, so the in-memory "user cleared this" ref is gone. The
|
||||
// first item must still not be re-applied.
|
||||
const setDataMaskMock = jest.fn();
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: true,
|
||||
extraFormData: {
|
||||
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
|
||||
},
|
||||
},
|
||||
filterState: { value: null },
|
||||
};
|
||||
|
||||
render(
|
||||
// @ts-expect-error
|
||||
<SelectFilterPlugin
|
||||
// @ts-expect-error
|
||||
{...transformProps(testProps)}
|
||||
setDataMask={setDataMaskMock}
|
||||
showOverflow={false}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: { value: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Let the re-validation effect run before asserting it did nothing
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
expect(setDataMaskMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterState: expect.objectContaining({ value: ['boy'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('resets dependent filter to first item when value does not exist in data', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
const testProps = {
|
||||
|
||||
@@ -157,7 +157,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
const [col] = groupby;
|
||||
const [initialColtypeMap] = useState(coltypeMap);
|
||||
const [search, setSearch] = useState('');
|
||||
const prevDataRef = useRef(data);
|
||||
const userClearedRef = useRef(false);
|
||||
const [dataMask, dispatchDataMask] = useImmerReducer(reducer, {
|
||||
extraFormData: {},
|
||||
@@ -272,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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -282,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) => {
|
||||
@@ -305,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 (
|
||||
@@ -337,7 +360,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
const unquotedSearch = stripSurroundingQuotes(search);
|
||||
if (
|
||||
unquotedSearch &&
|
||||
!searchAllOptions &&
|
||||
creatable !== false &&
|
||||
!hasOption(unquotedSearch, uniqueOptions, true)
|
||||
) {
|
||||
@@ -347,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) => {
|
||||
@@ -430,26 +452,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
clearAllTrigger,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevDataRef.current;
|
||||
const curr = data;
|
||||
|
||||
const hasDataChanged =
|
||||
prev?.length !== curr?.length ||
|
||||
prev?.some((row, i) => {
|
||||
const prevVal = row[col];
|
||||
const currVal = curr[i][col];
|
||||
return typeof prevVal === 'bigint' || typeof currVal === 'bigint'
|
||||
? prevVal?.toString() !== currVal?.toString()
|
||||
: prevVal !== currVal;
|
||||
});
|
||||
|
||||
// If data actually changed (e.g., due to parent filter), reset flag
|
||||
if (hasDataChanged) {
|
||||
prevDataRef.current = data;
|
||||
}
|
||||
}, [data, col]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
filterState.value?.every((value?: any) =>
|
||||
@@ -462,13 +464,17 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
? (groupby.map(col => data[0][col]) as string[])
|
||||
: null;
|
||||
|
||||
// Skip default value update when clearAllTrigger is active
|
||||
// Skip default value update when clearAllTrigger is active.
|
||||
// `null` is a persisted "user cleared this" state, as opposed to
|
||||
// `undefined` for "never set", so it must not be re-defaulted either —
|
||||
// `userClearedRef` alone would not survive a reload.
|
||||
if (
|
||||
!clearAllTrigger &&
|
||||
defaultToFirstItem &&
|
||||
!userClearedRef.current &&
|
||||
Object.keys(formData?.extraFormData || {}).length &&
|
||||
filterState.value !== undefined &&
|
||||
filterState.value !== null &&
|
||||
firstItem !== null &&
|
||||
filterState.value !== firstItem
|
||||
) {
|
||||
@@ -634,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}
|
||||
@@ -643,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: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user