mirror of
https://github.com/apache/superset.git
synced 2026-06-12 02:59:27 +00:00
Compare commits
242 Commits
enxdev/fix
...
compact-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aff407d730 | ||
|
|
ac778078de | ||
|
|
fc27892a7d | ||
|
|
9c2a98d29f | ||
|
|
49e900ba75 | ||
|
|
b2b7d737b5 | ||
|
|
4e34961d10 | ||
|
|
0f8bdfffb9 | ||
|
|
d0a72c572c | ||
|
|
863f8b0b5c | ||
|
|
e5e90ed131 | ||
|
|
7523f08433 | ||
|
|
43d14d13da | ||
|
|
68f0aecc79 | ||
|
|
83fd7cea81 | ||
|
|
33ee1826f7 | ||
|
|
809692fb61 | ||
|
|
9ebc9d42c6 | ||
|
|
25a80cf3ed | ||
|
|
f194fbe1da | ||
|
|
02d365aba6 | ||
|
|
4eb4d1ce44 | ||
|
|
c59e928fd2 | ||
|
|
0f1f274037 | ||
|
|
d99f2caf0a | ||
|
|
4aefa4a627 | ||
|
|
3b06d452c8 | ||
|
|
1148ad14f4 | ||
|
|
c0564c77af | ||
|
|
ee03b18bfa | ||
|
|
a1bbdd06ca | ||
|
|
4f7164681f | ||
|
|
c4d632a0b1 | ||
|
|
bd1751ae3f | ||
|
|
59a236a566 | ||
|
|
89b87591a0 | ||
|
|
466df0e657 | ||
|
|
8d8b76f3a0 | ||
|
|
f9131a7784 | ||
|
|
3c0f4e8852 | ||
|
|
39cd510e97 | ||
|
|
582ffc3de1 | ||
|
|
343a754108 | ||
|
|
198a25ed35 | ||
|
|
2a73a3298a | ||
|
|
c71de697cf | ||
|
|
12a3cd97fd | ||
|
|
471cd89d5f | ||
|
|
b467f9ab95 | ||
|
|
34f847fff7 | ||
|
|
5d3b8309ac | ||
|
|
cff42bae8f | ||
|
|
297fb5211e | ||
|
|
a2e4e5b62c | ||
|
|
15f7a7c9d8 | ||
|
|
9a55927575 | ||
|
|
2abe47cdfa | ||
|
|
1d873ea96b | ||
|
|
527f127f93 | ||
|
|
13e32fb3ff | ||
|
|
189a55549b | ||
|
|
afef786419 | ||
|
|
c2b1e1e539 | ||
|
|
e9e2a93105 | ||
|
|
43f6edf2d2 | ||
|
|
6941f69396 | ||
|
|
2797b4e3ed | ||
|
|
a3ea617aa4 | ||
|
|
66090905e5 | ||
|
|
18c2da79b4 | ||
|
|
aefa459e89 | ||
|
|
5523a416da | ||
|
|
af069f93ff | ||
|
|
fa8cfa1f9b | ||
|
|
3415a61087 | ||
|
|
b0024d7a36 | ||
|
|
3b96e6f471 | ||
|
|
6842bb3186 | ||
|
|
97dd0fb58a | ||
|
|
e8b6a9f674 | ||
|
|
519606e93a | ||
|
|
712b29df55 | ||
|
|
81991e5696 | ||
|
|
51cb17c85b | ||
|
|
1385c05ed4 | ||
|
|
982c0208b3 | ||
|
|
d58252b7a7 | ||
|
|
4ba113e9b4 | ||
|
|
38f1bef50a | ||
|
|
4cc71f49e6 | ||
|
|
04aa096a73 | ||
|
|
86580d3693 | ||
|
|
5348a68510 | ||
|
|
b093a0357c | ||
|
|
0b0b887b4a | ||
|
|
ac3d3f687b | ||
|
|
57c44bf1d4 | ||
|
|
45849c4116 | ||
|
|
15054d4298 | ||
|
|
b89eca9141 | ||
|
|
6f8d9e61a9 | ||
|
|
a5002f7709 | ||
|
|
60cc8ccab4 | ||
|
|
b774a5018d | ||
|
|
48bd635065 | ||
|
|
8261f40705 | ||
|
|
149501c879 | ||
|
|
bf7bd149ff | ||
|
|
bfaac143be | ||
|
|
5983d542e3 | ||
|
|
729499dc43 | ||
|
|
719572264f | ||
|
|
7738dd8f9e | ||
|
|
d62f92a685 | ||
|
|
93b0e2ab2d | ||
|
|
d49030169c | ||
|
|
7f16e9eab7 | ||
|
|
1892c16b97 | ||
|
|
b5f5def641 | ||
|
|
e1a407d68e | ||
|
|
26ab78695c | ||
|
|
571b997c08 | ||
|
|
502cd76d69 | ||
|
|
18694e8bcf | ||
|
|
0d7655b712 | ||
|
|
4a5c76b358 | ||
|
|
c8f6a606d2 | ||
|
|
cb853fe5b1 | ||
|
|
b56442ef74 | ||
|
|
987cd1e91d | ||
|
|
2fd5492ee0 | ||
|
|
0bf1958186 | ||
|
|
88203bdb63 | ||
|
|
00858d0af8 | ||
|
|
888cf905cf | ||
|
|
706c45fa92 | ||
|
|
e33a9973d6 | ||
|
|
092bcd0da8 | ||
|
|
00bd9d2ac1 | ||
|
|
c7c3d411c6 | ||
|
|
211f7bd87c | ||
|
|
cb89f9de0f | ||
|
|
89b3ae845c | ||
|
|
7d0a3364af | ||
|
|
87c848c2f1 | ||
|
|
2531a166bd | ||
|
|
c7d8bc55c1 | ||
|
|
357ed59076 | ||
|
|
a4040c7778 | ||
|
|
60061b9ee9 | ||
|
|
096681eb03 | ||
|
|
d4ecb1ba6f | ||
|
|
b4a831f6fc | ||
|
|
6ec5e05d9b | ||
|
|
b5176e17fd | ||
|
|
9ca6ccbe3a | ||
|
|
bf215f722c | ||
|
|
d44ed6ed82 | ||
|
|
60d2755b65 | ||
|
|
9d6f99adec | ||
|
|
001157a777 | ||
|
|
1bc90a06ee | ||
|
|
aa29c98ee9 | ||
|
|
66a0c92c96 | ||
|
|
347f9fffad | ||
|
|
3f71b283b0 | ||
|
|
ec3525c0e8 | ||
|
|
f1ce42c5b5 | ||
|
|
d109a04e7c | ||
|
|
f83be777c4 | ||
|
|
3a26426431 | ||
|
|
bf935d5541 | ||
|
|
02aa17bd68 | ||
|
|
aa6f0c1ad3 | ||
|
|
7d07ab790b | ||
|
|
b0cd86adb9 | ||
|
|
5bba832131 | ||
|
|
eccdbdd677 | ||
|
|
ee86d902d3 | ||
|
|
83493ce39c | ||
|
|
e579f90dc0 | ||
|
|
55e653cdcc | ||
|
|
87a52523a0 | ||
|
|
cef55d4d4f | ||
|
|
c5a7f0e7ad | ||
|
|
4ac50b3072 | ||
|
|
98d31d8d9b | ||
|
|
82e801ee52 | ||
|
|
239d452d5a | ||
|
|
584925b68d | ||
|
|
30e67f6798 | ||
|
|
4e16ac1ccb | ||
|
|
cc749b1723 | ||
|
|
0e170e1387 | ||
|
|
2fd22e8e98 | ||
|
|
329f8e2400 | ||
|
|
1256e7a867 | ||
|
|
897a1d7d2c | ||
|
|
a28632c68b | ||
|
|
c1f0b180bc | ||
|
|
fca825625d | ||
|
|
673b135adc | ||
|
|
51cec9c24f | ||
|
|
65012bcad8 | ||
|
|
8dc9ae5930 | ||
|
|
d6ced441de | ||
|
|
0dfd3e4045 | ||
|
|
3118daa63a | ||
|
|
5e3419fe28 | ||
|
|
77779d7bda | ||
|
|
1ec5abc60e | ||
|
|
94aa03bd3d | ||
|
|
ade1a0e5e2 | ||
|
|
a704330400 | ||
|
|
6c49ad74f9 | ||
|
|
345d87b4b0 | ||
|
|
d013003cf1 | ||
|
|
75d731398e | ||
|
|
6a16e7dca4 | ||
|
|
eb945f8289 | ||
|
|
96234d2cfe | ||
|
|
541cfd989c | ||
|
|
3c1f1d5535 | ||
|
|
97c22497f4 | ||
|
|
f28a8f6f78 | ||
|
|
b868d3c7bf | ||
|
|
765d9d39a9 | ||
|
|
d520d461b3 | ||
|
|
926b9b2311 | ||
|
|
934443e09f | ||
|
|
7d5b0e35e2 | ||
|
|
6224bc7aec | ||
|
|
1b5a31a203 | ||
|
|
7812f64278 | ||
|
|
12621e3e97 | ||
|
|
81adabf667 | ||
|
|
bff26e9256 | ||
|
|
8fa074d3a1 | ||
|
|
f984153936 | ||
|
|
c09295aa52 | ||
|
|
002cb30a44 | ||
|
|
6540353960 |
16
.github/SECURITY.md
vendored
16
.github/SECURITY.md
vendored
@@ -33,13 +33,21 @@ We kindly ask you to include the following information in your report to assist
|
||||
- Expected vs. Actual Behavior: A clear description of the intended system behavior versus the observed vulnerability.
|
||||
- Detailed Reproduction Steps: Clear, manual steps to reproduce the vulnerability.
|
||||
|
||||
**Vulnerability Definition**
|
||||
|
||||
Apache Superset considers a security vulnerability to be a demonstrable issue that has meaningful impact on confidentiality, integrity, or availability beyond the intended security model. Low-impact boundary variations or technical edge cases in existing access controls may be classified as hardening improvements rather than vulnerabilities, even if exploitable.
|
||||
|
||||
**Out of Scope Vulnerabilities**
|
||||
|
||||
To prioritize engineering efforts on genuine architectural risks, the following scenarios are explicitly out of scope and will not be issued a CVE:
|
||||
- Attacks requiring Admin privileges: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- Brute Force and Rate Limiting: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- Theoretical attack vectors: Issues without a demonstrable, reproducible exploit path.
|
||||
- Non-Exploitable Findings: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
- **Attacks requiring Admin privileges**: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- **Brute Force and Rate Limiting**: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- **Theoretical attack vectors**: Issues without a demonstrable, reproducible exploit path.
|
||||
- **Non-Exploitable Findings**: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
- **User enumeration**: API responses, timing differences, or error messages that reveal whether user accounts, IDs, dashboards, or datasets exist.
|
||||
- **Information disclosure (low impact)**: Software version disclosure, generic error messages, stack traces without sensitive data exposure, or system configuration details that don't enable further exploitation.
|
||||
- **Resource exhaustion requiring authentication**: Denial of Service attacks that require valid user credentials and don't bypass rate limiting or resource controls.
|
||||
- **Missing security headers**: Without demonstration of a concrete exploit scenario that leverages the missing header.
|
||||
|
||||
**Outcome of Reports**
|
||||
|
||||
|
||||
9
.github/actions/setup-docker/action.yml
vendored
9
.github/actions/setup-docker/action.yml
vendored
@@ -27,6 +27,15 @@ runs:
|
||||
- name: Set up QEMU
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
with:
|
||||
# Pin the binfmt image to a specific QEMU release. The default
|
||||
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
|
||||
# QEMU's x86_64→aarch64 translator has been the proximate cause of
|
||||
# intermittent `exit code: 132` (SIGILL) failures during the arm64
|
||||
# leg of the multi-platform docker build — newer Node native modules
|
||||
# emit instructions QEMU's user-mode emulation occasionally drops on
|
||||
# the floor. Pinning a known-good release stabilises that path.
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
|
||||
61
.github/dependabot.yml
vendored
61
.github/dependabot.yml
vendored
@@ -1,7 +1,6 @@
|
||||
version: 2
|
||||
enable-beta-ecosystems: true
|
||||
updates:
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
ignore:
|
||||
@@ -10,6 +9,8 @@ updates:
|
||||
- dependency-name: anthropics/claude-code-action
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
ignore:
|
||||
@@ -57,6 +58,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 30
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
@@ -72,6 +75,8 @@ updates:
|
||||
labels:
|
||||
- pip
|
||||
- dependabot
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: ".github/actions"
|
||||
@@ -79,6 +84,8 @@ updates:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
@@ -102,6 +109,8 @@ updates:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/"
|
||||
@@ -111,6 +120,8 @@ updates:
|
||||
- npm
|
||||
- dependabot
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/utils/client-ws-app/"
|
||||
@@ -121,6 +132,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
# Now for all of our plugins and packages!
|
||||
|
||||
@@ -133,6 +146,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
|
||||
@@ -143,6 +158,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
|
||||
@@ -153,6 +170,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
|
||||
@@ -166,6 +185,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
|
||||
@@ -176,6 +197,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
|
||||
@@ -186,6 +209,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
|
||||
@@ -196,6 +221,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
|
||||
@@ -206,6 +233,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-table/"
|
||||
@@ -219,6 +248,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
|
||||
@@ -229,6 +260,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
|
||||
@@ -239,6 +272,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
|
||||
@@ -249,6 +284,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
|
||||
@@ -259,6 +296,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
|
||||
@@ -269,6 +308,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
|
||||
@@ -279,6 +320,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
|
||||
@@ -289,6 +332,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
|
||||
@@ -299,6 +344,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
|
||||
@@ -309,6 +356,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
|
||||
@@ -323,6 +372,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/generator-superset/"
|
||||
@@ -333,6 +384,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
|
||||
@@ -343,6 +396,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-core/"
|
||||
@@ -358,6 +413,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-switchboard/"
|
||||
@@ -368,3 +425,5 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
123
.github/workflows/bashlib.sh
vendored
123
.github/workflows/bashlib.sh
vendored
@@ -175,10 +175,13 @@ cypress-run-all() {
|
||||
local APP_ROOT=$2
|
||||
cd "$GITHUB_WORKSPACE/superset-frontend/cypress-base"
|
||||
|
||||
# Start Flask and run it in background
|
||||
# --no-debugger means disable the interactive debugger on the 500 page
|
||||
# so errors can print to stderr.
|
||||
local flasklog="${HOME}/flask.log"
|
||||
# Start the Superset backend via gunicorn (not `flask run`). The Flask
|
||||
# development server is single-threaded and has no crash-recovery, so
|
||||
# heavy tests (dashboard import/export, SQL Lab) can knock it offline
|
||||
# for the rest of the run — surfacing as `ECONNREFUSED` / `socket hang up`
|
||||
# / `Missing CSRF token` cascades. Gunicorn gives us multiple workers,
|
||||
# a request timeout, and worker-recycling under load.
|
||||
local serverlog="${HOME}/superset-cypress.log"
|
||||
local port=8081
|
||||
CYPRESS_BASE_URL="http://localhost:${port}"
|
||||
if [ -n "$APP_ROOT" ]; then
|
||||
@@ -187,8 +190,58 @@ cypress-run-all() {
|
||||
fi
|
||||
export CYPRESS_BASE_URL
|
||||
|
||||
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
|
||||
local flaskProcessId=$!
|
||||
# Mirrors the args in docker/entrypoints/run-server.sh (1 worker × 20
|
||||
# gthread threads) to keep parity with production. Multi-worker
|
||||
# configurations expose timing-sensitive races in the SQL Lab → Explore
|
||||
# navigation flow under E2E. We diverge from the entrypoint on:
|
||||
# --timeout 120: heavy dashboard import/export specs exceed the 60s
|
||||
# default
|
||||
# --max-requests / --max-requests-jitter: recycle the worker under
|
||||
# test load to avoid leaks accumulating across the run
|
||||
# superset.app:create_app(): explicit factory so we don't depend on
|
||||
# FLASK_APP being exported
|
||||
nohup gunicorn \
|
||||
--bind "127.0.0.1:$port" \
|
||||
--workers 1 \
|
||||
--worker-class gthread \
|
||||
--threads 20 \
|
||||
--timeout 120 \
|
||||
--max-requests 500 \
|
||||
--max-requests-jitter 50 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"superset.app:create_app()" \
|
||||
>"$serverlog" 2>&1 </dev/null &
|
||||
local serverPid=$!
|
||||
|
||||
# Ensure the backend is cleaned up and its log is emitted even when the
|
||||
# test runner fails under `set -e`.
|
||||
trap '
|
||||
echo "::group::gunicorn log for Cypress run"
|
||||
cat "'"$serverlog"'" || true
|
||||
echo "::endgroup::"
|
||||
kill '"$serverPid"' 2>/dev/null || true
|
||||
' EXIT
|
||||
|
||||
# Wait for the backend to be ready before launching Cypress; otherwise
|
||||
# the first spec can race the server bind and see connection errors.
|
||||
local timeout=60
|
||||
say "Waiting for gunicorn server to start on port $port..."
|
||||
while [ $timeout -gt 0 ]; do
|
||||
if curl -f "http://localhost:${port}${APP_ROOT}/health" >/dev/null 2>&1; then
|
||||
say "gunicorn server is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
timeout=$((timeout - 1))
|
||||
done
|
||||
if [ $timeout -eq 0 ]; then
|
||||
echo "::error::gunicorn server failed to start within 60 seconds"
|
||||
echo "::group::Server startup log"
|
||||
cat "$serverlog"
|
||||
echo "::endgroup::"
|
||||
return 1
|
||||
fi
|
||||
|
||||
USE_DASHBOARD_FLAG=''
|
||||
if [ "$USE_DASHBOARD" = "true" ]; then
|
||||
@@ -200,13 +253,6 @@ cypress-run-all() {
|
||||
# memoryMonitorPid=$!
|
||||
python ../../scripts/cypress_run.py --parallelism $PARALLELISM --parallelism-id $PARALLEL_ID --group $PARALLEL_ID --retries 5 $USE_DASHBOARD_FLAG
|
||||
# kill $memoryMonitorPid
|
||||
|
||||
# After job is done, print out Flask log for debugging
|
||||
echo "::group::Flask log for default run"
|
||||
cat "$flasklog"
|
||||
echo "::endgroup::"
|
||||
# make sure the program exits
|
||||
kill $flaskProcessId
|
||||
}
|
||||
|
||||
playwright-install() {
|
||||
@@ -224,9 +270,11 @@ playwright-run() {
|
||||
local APP_ROOT=$1
|
||||
local TEST_PATH=$2
|
||||
|
||||
# Start Flask from the project root (same as Cypress)
|
||||
# Start the Superset backend via gunicorn from the project root.
|
||||
# See cypress-run-all() above for the rationale — the Flask dev server
|
||||
# cannot survive the dashboard import/export tests under load.
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
local flasklog="${HOME}/flask-playwright.log"
|
||||
local serverlog="${HOME}/superset-playwright.log"
|
||||
local port=8081
|
||||
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
|
||||
if [ -n "$APP_ROOT" ]; then
|
||||
@@ -235,18 +283,37 @@ playwright-run() {
|
||||
fi
|
||||
export PLAYWRIGHT_BASE_URL
|
||||
|
||||
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
|
||||
local flaskProcessId=$!
|
||||
# See cypress-run-all() above for the args rationale (1 worker × 20
|
||||
# gthread threads matching docker/entrypoints/run-server.sh, plus a
|
||||
# 120s timeout and request-recycling for heavy E2E load).
|
||||
nohup gunicorn \
|
||||
--bind "127.0.0.1:$port" \
|
||||
--workers 1 \
|
||||
--worker-class gthread \
|
||||
--threads 20 \
|
||||
--timeout 120 \
|
||||
--max-requests 500 \
|
||||
--max-requests-jitter 50 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"superset.app:create_app()" \
|
||||
>"$serverlog" 2>&1 </dev/null &
|
||||
local serverPid=$!
|
||||
|
||||
# Ensure cleanup on exit
|
||||
trap "kill $flaskProcessId 2>/dev/null || true" EXIT
|
||||
# Ensure cleanup on exit (and emit the server log on failure)
|
||||
trap '
|
||||
echo "::group::gunicorn log for Playwright run"
|
||||
cat "'"$serverlog"'" || true
|
||||
echo "::endgroup::"
|
||||
kill '"$serverPid"' 2>/dev/null || true
|
||||
' EXIT
|
||||
|
||||
# Wait for server to be ready with health check
|
||||
local timeout=60
|
||||
say "Waiting for Flask server to start on port $port..."
|
||||
say "Waiting for gunicorn server to start on port $port..."
|
||||
while [ $timeout -gt 0 ]; do
|
||||
if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
|
||||
say "Flask server is ready"
|
||||
say "gunicorn server is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
@@ -254,9 +321,9 @@ playwright-run() {
|
||||
done
|
||||
|
||||
if [ $timeout -eq 0 ]; then
|
||||
echo "::error::Flask server failed to start within 60 seconds"
|
||||
echo "::group::Flask startup log"
|
||||
cat "$flasklog"
|
||||
echo "::error::gunicorn server failed to start within 60 seconds"
|
||||
echo "::group::Server startup log"
|
||||
cat "$serverlog"
|
||||
echo "::endgroup::"
|
||||
return 1
|
||||
fi
|
||||
@@ -271,7 +338,6 @@ playwright-run() {
|
||||
if ! find "playwright/tests/${TEST_PATH}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
|
||||
echo "No test files found in ${TEST_PATH} - skipping test run"
|
||||
say "::endgroup::"
|
||||
kill $flaskProcessId
|
||||
return 0
|
||||
fi
|
||||
echo "Running tests: ${TEST_PATH}"
|
||||
@@ -288,13 +354,6 @@ playwright-run() {
|
||||
fi
|
||||
say "::endgroup::"
|
||||
|
||||
# After job is done, print out Flask log for debugging
|
||||
echo "::group::Flask log for Playwright run"
|
||||
cat "$flasklog"
|
||||
echo "::endgroup::"
|
||||
# make sure the program exits
|
||||
kill $flaskProcessId
|
||||
|
||||
return $status
|
||||
}
|
||||
|
||||
|
||||
7
.github/workflows/no-hold-label.yml
vendored
7
.github/workflows/no-hold-label.yml
vendored
@@ -7,10 +7,13 @@ on:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
# Let each label event run to completion. Cancelling in-progress runs leaves
|
||||
# CANCELLED entries in the PR's check-suite rollup, which poisons GitHub's
|
||||
# `status:success` search filter even though all real CI passed. The job is
|
||||
# a tiny no-op github-script call, so the wasted compute is negligible.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check-hold-label:
|
||||
|
||||
4
.github/workflows/superset-e2e.yml
vendored
4
.github/workflows/superset-e2e.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
matrix:
|
||||
parallel_id: [0, 1, 2, 3, 4, 5]
|
||||
browser: ["chrome"]
|
||||
app_root: ["", "/app/prefix"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: ["chromium"]
|
||||
app_root: ["", "/app/prefix"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
|
||||
87
.github/workflows/superset-translations-comment.yml
vendored
Normal file
87
.github/workflows/superset-translations-comment.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Translation Regression Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Translations"]
|
||||
types: [completed]
|
||||
|
||||
# This workflow posts a PR comment when the Translations workflow detects a
|
||||
# regression. It uses the workflow_run trigger so that it always runs in the
|
||||
# base-branch context and can safely be granted write permissions, even for
|
||||
# PRs from forks.
|
||||
#
|
||||
# IMPORTANT: This workflow must NEVER check out code from the PR branch.
|
||||
# All data comes from the artifact uploaded by the Translations workflow.
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-comment:
|
||||
runs-on: ubuntu-24.04
|
||||
# Only act when the Translations workflow failed (which means a regression
|
||||
# was detected — the workflow exits 1 on regression).
|
||||
if: github.event.workflow_run.conclusion == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Download regression artifact
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: translation-regression
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: /tmp/translation-regression
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const prNumberFile = '/tmp/translation-regression/pr-number.txt';
|
||||
const reportFile = '/tmp/translation-regression/regression-report.md';
|
||||
|
||||
if (!fs.existsSync(prNumberFile) || !fs.existsSync(reportFile)) {
|
||||
console.log('Artifact files not found, skipping comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = parseInt(fs.readFileSync(prNumberFile, 'utf8').trim(), 10);
|
||||
if (!prNumber) {
|
||||
console.log('Could not parse PR number, skipping comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const report = fs.readFileSync(reportFile, 'utf8');
|
||||
const marker = '<!-- translation-regression-bot -->';
|
||||
const body = `${marker}\n${report}`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
console.log(`Updated existing comment ${existing.id} on PR #${prNumber}`);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
console.log(`Created new comment on PR #${prNumber}`);
|
||||
}
|
||||
90
.github/workflows/superset-translations.yml
vendored
90
.github/workflows/superset-translations.yml
vendored
@@ -20,6 +20,9 @@ concurrency:
|
||||
jobs:
|
||||
frontend-check-translations:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -51,12 +54,16 @@ jobs:
|
||||
|
||||
babel-extract:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
@@ -64,12 +71,85 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Python
|
||||
if: steps.check.outputs.python
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
uses: ./.github/actions/setup-backend/
|
||||
|
||||
- name: Install msgcat
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install gettext tools
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y gettext
|
||||
|
||||
- name: Test babel extraction
|
||||
if: steps.check.outputs.python
|
||||
# Fetch the base ref so we can compare PR-introduced regressions
|
||||
# against a fair baseline (also runs babel_update against the base
|
||||
# source) — this isolates the PR's contribution from any pre-existing
|
||||
# drift on the base branch.
|
||||
- name: Fetch base ref and create comparison worktree
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: |
|
||||
# For PRs use the base branch; for direct pushes compare against the previous commit.
|
||||
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||
if [ -n "$BASE_REF" ]; then
|
||||
git fetch --depth=1 origin "$BASE_REF"
|
||||
else
|
||||
git fetch --depth=2 origin "${{ github.ref }}"
|
||||
fi
|
||||
git worktree add /tmp/base-worktree FETCH_HEAD
|
||||
|
||||
# Run babel_update against BASE source + BASE translations. Any drift
|
||||
# already present on the base branch (source strings that have changed
|
||||
# without .po updates) shows up here as fuzzies — and will also show
|
||||
# up in the PR run, so it cancels out in the comparison.
|
||||
- name: Baseline — run babel_update against BASE source
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
working-directory: /tmp/base-worktree
|
||||
run: ./scripts/translations/babel_update.sh
|
||||
|
||||
- name: Record baseline translation counts
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: |
|
||||
python scripts/translations/check_translation_regression.py \
|
||||
--count \
|
||||
--translations-dir /tmp/base-worktree/superset/translations \
|
||||
> /tmp/before.json
|
||||
|
||||
# Reset the PR worktree's translations to the pristine BASE state so
|
||||
# both babel_update runs start from the same .po files. The only
|
||||
# difference between the runs is the source code.
|
||||
- name: Reset PR worktree translations to pristine BASE
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: git checkout FETCH_HEAD -- superset/translations/
|
||||
|
||||
- name: Run babel_update against PR source
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: ./scripts/translations/babel_update.sh
|
||||
|
||||
- name: Check for translation regression
|
||||
id: regression
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python scripts/translations/check_translation_regression.py \
|
||||
--compare /tmp/before.json \
|
||||
--report /tmp/regression-report.md
|
||||
|
||||
# Save the PR number so the comment workflow can post the report without
|
||||
# needing write permissions on this pull_request-triggered job.
|
||||
- name: Save PR number for comment workflow
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.regression.outcome == 'failure'
|
||||
run: echo "${{ github.event.pull_request.number }}" > /tmp/pr-number.txt
|
||||
|
||||
- name: Upload regression artifact
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.regression.outcome == 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: translation-regression
|
||||
path: |
|
||||
/tmp/regression-report.md
|
||||
/tmp/pr-number.txt
|
||||
|
||||
- name: Fail if regression detected
|
||||
if: steps.regression.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -115,6 +115,8 @@ release.json
|
||||
superset/translations/**/messages.json
|
||||
# these mo binary files are generated by `pybabel compile`
|
||||
superset/translations/**/messages.mo
|
||||
# cross-language index generated by scripts/translations/build_translation_index.py
|
||||
superset/translations/translation_index.json
|
||||
|
||||
docker/requirements-local.txt
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ repos:
|
||||
hooks:
|
||||
- id: check-docstring-first
|
||||
- id: check-added-large-files
|
||||
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$
|
||||
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$|^superset/translations/.*\.po$
|
||||
- id: check-yaml
|
||||
exclude: ^helm/superset/templates/
|
||||
- id: debug-statements
|
||||
|
||||
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=all
|
||||
enable=json-import,disallowed-sql-import,consider-using-transaction
|
||||
enable=disallowed-sql-import,consider-using-transaction
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
@@ -202,6 +202,8 @@ RUN mkdir -p /app/data && chown -R superset:superset /app/data
|
||||
|
||||
# Copy compiled things from previous stages
|
||||
COPY --from=superset-node /app/superset/static/assets superset/static/assets
|
||||
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
|
||||
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
|
||||
|
||||
# TODO, when the next version comes out, use --exclude superset/translations
|
||||
COPY superset superset
|
||||
|
||||
@@ -88,7 +88,6 @@ using our `docker compose` constructs to support production-type use-cases. For
|
||||
environments, we recommend using [minikube](https://minikube.sigs.k8s.io/docs/start/) along
|
||||
our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
|
||||
documentation.
|
||||
configured to be secure.
|
||||
:::
|
||||
|
||||
### Supported environment variables
|
||||
|
||||
@@ -335,6 +335,92 @@ npm run build-translation
|
||||
pybabel compile -d superset/translations
|
||||
```
|
||||
|
||||
### Backfilling missing translations with AI
|
||||
|
||||
For languages with many untranslated strings, the repo includes a script that
|
||||
uses Claude AI to generate draft translations for any missing entries. All
|
||||
AI-generated strings are marked `#, fuzzy` and tagged with an attribution
|
||||
comment so that human reviewers know they need to be checked before merging.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
```bash
|
||||
pip install -r superset/translations/requirements.txt
|
||||
```
|
||||
|
||||
Claude Code must be installed and authenticated (`claude --version` should
|
||||
work). The script calls `claude -p` internally — no separate API key is needed.
|
||||
|
||||
#### Step 1 — Build the translation index
|
||||
|
||||
The index captures every already-translated string in every language and
|
||||
serves as cross-language context for the AI. Rebuild it whenever `.po` files
|
||||
change significantly:
|
||||
|
||||
```bash
|
||||
python scripts/translations/build_translation_index.py
|
||||
# Writes: superset/translations/translation_index.json
|
||||
```
|
||||
|
||||
#### Step 2 — Preview with a dry run
|
||||
|
||||
Check what would be translated without writing anything:
|
||||
|
||||
```bash
|
||||
python scripts/translations/backfill_po.py --lang fr --limit 20 --dry-run
|
||||
```
|
||||
|
||||
Output shows each string, its translation, and a context tag:
|
||||
- No tag — 3+ reference languages available (high confidence)
|
||||
- `[ctx:N]` — only N other languages have this string (lower confidence)
|
||||
- `[ctx:0]` — no other language has this string yet; English alone used
|
||||
|
||||
#### Step 3 — Run the backfill
|
||||
|
||||
```bash
|
||||
python scripts/translations/backfill_po.py --lang fr
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--lang LANG` | required | ISO language code (`fr`, `de`, `ja`, …) |
|
||||
| `--batch-size N` | 50 | Strings per Claude request |
|
||||
| `--limit N` | unlimited | Stop after N entries |
|
||||
| `--min-context N` | 0 | Skip entries with fewer than N reference translations |
|
||||
| `--model MODEL` | `claude-sonnet-4-6` | Claude model to use |
|
||||
| `--dry-run` | off | Print without writing |
|
||||
| `--no-fuzzy` | off | Don't mark entries as fuzzy |
|
||||
|
||||
Use `--min-context 2` to skip strings that have fewer than 2 reference
|
||||
translations in other languages. Those strings are more likely to be ambiguous
|
||||
(short labels, UI fragments) where the correct meaning can't be inferred
|
||||
without additional context.
|
||||
|
||||
#### Step 4 — Review and commit
|
||||
|
||||
Open the target `.po` file and search for `fuzzy`. For each generated entry:
|
||||
|
||||
1. Verify the translation is correct for the UI context.
|
||||
2. Remove the `# Machine-translated via backfill_po.py` comment and the
|
||||
`#, fuzzy` flag line once you are satisfied.
|
||||
3. If the translation is wrong, correct the `msgstr` before removing the flag.
|
||||
4. Commit the `.po` file — do **not** commit `translation_index.json` (it is
|
||||
gitignored and regenerated locally).
|
||||
|
||||
#### Running via npm
|
||||
|
||||
From `superset-frontend/`:
|
||||
|
||||
```bash
|
||||
# Rebuild index
|
||||
npm run translations:build-index
|
||||
|
||||
# Backfill (pass arguments after --)
|
||||
npm run translations:backfill -- --lang fr --dry-run
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
### Python
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.0"
|
||||
"webpack": "^5.107.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -12270,14 +12270,7 @@ pvutils@^1.1.3, pvutils@^1.1.5:
|
||||
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.5.tgz#84b0dea4a5d670249aa9800511804ee0b7c2809c"
|
||||
integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==
|
||||
|
||||
qs@^6.12.3:
|
||||
version "6.14.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c"
|
||||
integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==
|
||||
dependencies:
|
||||
side-channel "^1.1.0"
|
||||
|
||||
qs@~6.15.1:
|
||||
qs@^6.12.3, qs@~6.15.1:
|
||||
version "6.15.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9"
|
||||
integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
|
||||
@@ -14964,10 +14957,10 @@ webpack-virtual-modules@^0.6.2:
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.107.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.0.tgz#9e0d8d8baf24e76f058103f4f06ac6bb528b645a"
|
||||
integrity sha512-PSxeHk/dmLYZlnTU+vL1Gej6Evg5RNtl3flhxBresfznFnzxinHMzHKloHnywM/3ouQv7/AlZCswWDIkNSggUA==
|
||||
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
|
||||
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
@@ -58,7 +58,7 @@ dependencies = [
|
||||
"flask-wtf>=1.1.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet>=3.0.3, <=3.5.0",
|
||||
"gunicorn>=22.0.0; sys_platform != 'win32'",
|
||||
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.45, <1",
|
||||
@@ -66,7 +66,7 @@ dependencies = [
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.6.1, <2",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.0",
|
||||
"markdown>=3.10.2",
|
||||
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
|
||||
"marshmallow>=3.0, <4",
|
||||
"marshmallow-union>=0.1",
|
||||
@@ -101,7 +101,7 @@ dependencies = [
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
"sqlglot>=30.8.0, <31",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.9.0, <1.0",
|
||||
"typing-extensions>=4, <5",
|
||||
@@ -137,7 +137,7 @@ databricks = [
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
@@ -220,6 +220,7 @@ development = [
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
"polib", # used by scripts/translations/ and their unit tests
|
||||
"pre-commit",
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
|
||||
@@ -166,7 +166,7 @@ greenlet==3.1.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==23.0.0
|
||||
gunicorn==25.3.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
@@ -213,7 +213,7 @@ mako==1.3.11
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
markdown==3.8.1
|
||||
markdown==3.10.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
@@ -415,7 +415,7 @@ sqlalchemy-utils==0.42.0
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==28.10.0
|
||||
sqlglot==30.8.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -388,7 +388,7 @@ grpcio==1.71.0
|
||||
# grpcio-status
|
||||
grpcio-status==1.60.1
|
||||
# via google-api-core
|
||||
gunicorn==23.0.0
|
||||
gunicorn==25.3.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -511,7 +511,7 @@ mako==1.3.11
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
markdown==3.8.1
|
||||
markdown==3.10.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -677,6 +677,8 @@ ply==3.11
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# jsonpath-ng
|
||||
polib==1.2.0
|
||||
# via apache-superset
|
||||
polyline==2.0.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -985,7 +987,7 @@ sqlalchemy-utils==0.42.0
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==28.10.0
|
||||
sqlglot==30.8.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -n "$output" ] && echo "$output"
|
||||
if [ -n "$output" ]; then echo "$output"; fi
|
||||
else
|
||||
echo "No JavaScript/TypeScript files to lint"
|
||||
fi
|
||||
|
||||
653
scripts/translations/backfill_po.py
Normal file
653
scripts/translations/backfill_po.py
Normal file
@@ -0,0 +1,653 @@
|
||||
# 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.
|
||||
"""Backfill missing translations in a .po file using Claude AI.
|
||||
|
||||
For each untranslated (empty msgstr) entry in the target language, the script
|
||||
sends the English source string along with all available translations in other
|
||||
languages to Claude as context, then writes the AI-generated translation back
|
||||
into the .po file marked as #, fuzzy for human review.
|
||||
|
||||
Usage:
|
||||
# Build the translation index first (one-time or when .po files change)
|
||||
python scripts/translations/build_translation_index.py
|
||||
|
||||
# Backfill French translations
|
||||
python scripts/translations/backfill_po.py --lang fr
|
||||
|
||||
# Dry run (print what would be translated without writing)
|
||||
python scripts/translations/backfill_po.py --lang de --dry-run
|
||||
|
||||
# Limit to 100 entries and use a specific model
|
||||
python scripts/translations/backfill_po.py --lang es --limit 100 \
|
||||
--model claude-opus-4-6
|
||||
|
||||
Options:
|
||||
--lang LANG ISO language code to backfill (required)
|
||||
--batch-size N Number of strings per Claude request (default: 50)
|
||||
--limit N Stop after translating N entries (default: unlimited)
|
||||
--min-context N Skip entries with fewer than N existing translations across
|
||||
reference languages (default: 0 — translate everything)
|
||||
--model MODEL Claude model ID (default: claude-sonnet-4-6)
|
||||
--index PATH Path to translation_index.json (default: auto-detect)
|
||||
--dry-run Print translations without writing to .po file
|
||||
--no-fuzzy Do not mark generated translations as fuzzy (default: mark fuzzy)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import polib # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
print("polib is required. Run: pip install polib", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
|
||||
DEFAULT_INDEX = TRANSLATIONS_DIR / "translation_index.json"
|
||||
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||
DEFAULT_BATCH_SIZE = 50
|
||||
|
||||
# Language names for the prompt, keyed by ISO code
|
||||
LANGUAGE_NAMES: dict[str, str] = {
|
||||
"ar": "Arabic",
|
||||
"ca": "Catalan",
|
||||
"de": "German",
|
||||
"es": "Spanish",
|
||||
"fa": "Persian (Farsi)",
|
||||
"fr": "French",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"mi": "Māori",
|
||||
"nl": "Dutch",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"pt_BR": "Brazilian Portuguese",
|
||||
"ru": "Russian",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"tr": "Turkish",
|
||||
"uk": "Ukrainian",
|
||||
"zh": "Chinese (Simplified)",
|
||||
"zh_TW": "Chinese (Traditional)",
|
||||
}
|
||||
|
||||
|
||||
def _lang_name(code: str) -> str:
|
||||
"""Return a human-readable language name for an ISO language code."""
|
||||
return LANGUAGE_NAMES.get(code, code)
|
||||
|
||||
|
||||
def _plural_key(msgid: str, msgid_plural: str) -> str:
|
||||
"""Build the translation index key used for pluralized entries."""
|
||||
return f"{msgid}\x00{msgid_plural}"
|
||||
|
||||
|
||||
def _is_missing(entry: polib.POEntry) -> bool:
|
||||
"""Return True for entries that need a translation."""
|
||||
if entry.obsolete:
|
||||
return False
|
||||
if entry.msgid_plural:
|
||||
return not any(v for v in entry.msgstr_plural.values())
|
||||
return not entry.msgstr
|
||||
|
||||
|
||||
def _context_langs(
|
||||
item: dict[str, Any], index: dict[str, Any], target_lang: str
|
||||
) -> list[str]:
|
||||
"""Return sorted list of language codes that have translations for this entry."""
|
||||
key = item["index_key"]
|
||||
if key not in index:
|
||||
return []
|
||||
return sorted(
|
||||
lang for lang, val in index[key].items() if lang != target_lang and val
|
||||
)
|
||||
|
||||
|
||||
def _context_count(
|
||||
item: dict[str, Any], index: dict[str, Any], target_lang: str
|
||||
) -> int:
|
||||
"""Return the number of other-language translations available for this entry."""
|
||||
return len(_context_langs(item, index, target_lang))
|
||||
|
||||
|
||||
def _render_item(
|
||||
i: int,
|
||||
item: dict[str, Any],
|
||||
index: dict[str, Any],
|
||||
target_lang: str,
|
||||
reference_langs_sorted: list[str],
|
||||
) -> list[str]:
|
||||
"""Render one batch entry as prompt lines."""
|
||||
lines: list[str] = []
|
||||
ctx = _context_count(item, index, target_lang)
|
||||
if ctx == 0:
|
||||
lines.append(
|
||||
f"--- [{i}] (no reference translations — translate conservatively) ---"
|
||||
)
|
||||
else:
|
||||
plural = "s" if ctx != 1 else ""
|
||||
lines.append(f"--- [{i}] ({ctx} reference translation{plural}) ---")
|
||||
lines.append(f"English: {json.dumps(item['msgid'], ensure_ascii=False)}")
|
||||
if item.get("msgid_plural"):
|
||||
plural_json = json.dumps(item["msgid_plural"], ensure_ascii=False)
|
||||
lines.append(f"English plural: {plural_json}")
|
||||
key = item["index_key"]
|
||||
if key in index and reference_langs_sorted:
|
||||
for lang in reference_langs_sorted:
|
||||
val = index[key].get(lang)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, dict):
|
||||
forms = "; ".join(
|
||||
f"[{k}] {json.dumps(v, ensure_ascii=False)}" for k, v in val.items()
|
||||
)
|
||||
lines.append(f"{_lang_name(lang)}: {forms}")
|
||||
else:
|
||||
lines.append(
|
||||
f"{_lang_name(lang)}: {json.dumps(val, ensure_ascii=False)}"
|
||||
)
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def build_prompt(
|
||||
target_lang: str,
|
||||
batch: list[dict[str, Any]],
|
||||
index: dict[str, Any],
|
||||
) -> str:
|
||||
"""Build the Claude prompt for a batch of entries."""
|
||||
lang_name = _lang_name(target_lang)
|
||||
|
||||
# Collect which other languages actually have translations for this batch
|
||||
reference_langs: set[str] = set()
|
||||
for item in batch:
|
||||
key = item["index_key"]
|
||||
if key in index:
|
||||
reference_langs.update(
|
||||
lang for lang, val in index[key].items() if lang != target_lang and val
|
||||
)
|
||||
reference_langs_sorted = sorted(reference_langs)
|
||||
|
||||
lines: list[str] = [
|
||||
"You are a professional translator specializing in software UI strings.",
|
||||
f"Translate the following English strings into {lang_name} ({target_lang}).",
|
||||
"",
|
||||
"Rules:",
|
||||
"- Preserve all format placeholders exactly: %(name)s, {name}, %s, %d, etc.",
|
||||
"- Preserve HTML tags if present.",
|
||||
"- Keep the same tone and register as the reference translations.",
|
||||
"- For plural forms, provide translations for all plural forms"
|
||||
" required by the language.",
|
||||
"- Return ONLY a JSON object mapping each numeric index (as a string)"
|
||||
" to its translation.",
|
||||
"- Do not add any explanation, preamble, or markdown fences.",
|
||||
"",
|
||||
"Important: Many strings are short fragments or single words that are"
|
||||
" ambiguous in English (e.g. 'Scale' could mean a measurement scale,"
|
||||
" to scale an image, or fish scales). Use the translations in other"
|
||||
" languages as your primary signal for which meaning is intended —"
|
||||
" they collectively disambiguate the intended sense. When no"
|
||||
" other-language translations are available for an entry, translate"
|
||||
" conservatively based on the most common meaning in a data"
|
||||
" visualization UI context.",
|
||||
"",
|
||||
]
|
||||
|
||||
if reference_langs_sorted:
|
||||
lines.append(
|
||||
f"Reference translations are provided per string where available "
|
||||
f"({', '.join(_lang_name(lc) for lc in reference_langs_sorted)})."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("Strings to translate:")
|
||||
lines.append("")
|
||||
|
||||
for i, item in enumerate(batch):
|
||||
lines.extend(_render_item(i, item, index, target_lang, reference_langs_sorted))
|
||||
|
||||
# Add guidance on plural form counts per language whenever ANY entry in
|
||||
# the batch is plural — batches mix singular and plural in .po order, so
|
||||
# gating on the first entry would silently drop the guidance whenever
|
||||
# the plural entries happen to land after a singular one.
|
||||
if any(item.get("msgid_plural") for item in batch):
|
||||
lines.append(
|
||||
"Note: provide ALL plural forms required by the target language "
|
||||
"(e.g. French needs 2, Russian needs 3, Arabic needs 6)."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append(
|
||||
'Expected output format: {"0": "<translation>", "1": "<translation>", ...}'
|
||||
)
|
||||
lines.append("(keys are the numeric indices of the strings above)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_response(text: str, batch_size: int) -> dict[int, str]:
|
||||
"""Parse the JSON object from Claude's response."""
|
||||
# Strip any accidental markdown fences
|
||||
text = re.sub(r"^```[^\n]*\n", "", text.strip())
|
||||
text = re.sub(r"\n```$", "", text)
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"Could not parse response as JSON: {exc}\n\nResponse:\n{text}"
|
||||
) from exc
|
||||
# _process_batches only catches ValueError/RuntimeError, so a non-object
|
||||
# response (list, scalar, null) must surface as ValueError rather than
|
||||
# bubbling up an AttributeError from .items() and aborting the whole run.
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(
|
||||
f"Expected a JSON object mapping indices to translations, "
|
||||
f"got {type(raw).__name__}.\n\nResponse:\n{text}"
|
||||
)
|
||||
# Preserve dict/list values as JSON strings so plural responses (where
|
||||
# v is a dict of plural forms) can be re-parsed downstream by
|
||||
# _apply_translation's json.loads. str(v) on a dict produces Python
|
||||
# repr ({'0': 'x'}) which is not valid JSON.
|
||||
return {
|
||||
int(k): (
|
||||
json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else str(v)
|
||||
)
|
||||
for k, v in raw.items()
|
||||
if str(k).isdigit()
|
||||
}
|
||||
|
||||
|
||||
def translate_batch(
|
||||
model: str,
|
||||
target_lang: str,
|
||||
batch: list[dict[str, Any]],
|
||||
index: dict[str, Any],
|
||||
) -> dict[int, str]:
|
||||
"""Send a batch of strings to Claude via `claude -p`.
|
||||
|
||||
Returns a dict mapping batch index to translated string.
|
||||
"""
|
||||
claude_bin = shutil.which("claude")
|
||||
if not claude_bin:
|
||||
raise RuntimeError(
|
||||
"claude CLI not found. Install Claude Code or add it to PATH."
|
||||
)
|
||||
prompt = build_prompt(target_lang, batch, index)
|
||||
# Pipe the prompt over stdin rather than passing it as argv: a single batch
|
||||
# with many reference languages can grow into the tens of KB and approach
|
||||
# ARG_MAX on some platforms.
|
||||
# claude_bin is resolved via shutil.which — not user-controlled input
|
||||
result = subprocess.run( # noqa: S603
|
||||
[claude_bin, "--model", model, "-p"],
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"claude exited with code {result.returncode}:\n{result.stderr}"
|
||||
)
|
||||
return parse_response(result.stdout.strip(), len(batch))
|
||||
|
||||
|
||||
def _apply_plural_translation(entry: polib.POEntry, translation: str) -> None:
|
||||
"""Distribute a model response across the entry's plural forms.
|
||||
|
||||
Model may return a JSON dict ({"0": "form0", "1": "form1"}), a JSON list
|
||||
(["form0", "form1"], also valid since plural forms are ordered), a JSON
|
||||
scalar (a single translation that fills every form), or a plain non-JSON
|
||||
string (older models that ignore the JSON instruction).
|
||||
"""
|
||||
try:
|
||||
plural_value = json.loads(translation)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
for k in entry.msgstr_plural:
|
||||
entry.msgstr_plural[k] = translation
|
||||
return
|
||||
|
||||
if isinstance(plural_value, dict):
|
||||
entry.msgstr_plural = {int(k): str(v) for k, v in plural_value.items()}
|
||||
return
|
||||
|
||||
if isinstance(plural_value, list) and plural_value:
|
||||
# Distribute list items across plural form indices in order; if the
|
||||
# model returned fewer forms than the language requires, repeat the
|
||||
# last form rather than leaving slots blank.
|
||||
forms = [str(v) for v in plural_value]
|
||||
for k in sorted(entry.msgstr_plural):
|
||||
entry.msgstr_plural[k] = forms[k] if k < len(forms) else forms[-1]
|
||||
return
|
||||
|
||||
# Scalar (or empty list) — broadcast to every form.
|
||||
fill = str(plural_value) if plural_value not in (None, []) else translation
|
||||
for k in entry.msgstr_plural:
|
||||
entry.msgstr_plural[k] = fill
|
||||
|
||||
|
||||
def _apply_translation(
|
||||
entry: polib.POEntry,
|
||||
translation: str,
|
||||
item: dict[str, Any],
|
||||
model: str,
|
||||
mark_fuzzy: bool,
|
||||
) -> None:
|
||||
"""Write a translation string into a POEntry and add attribution."""
|
||||
if entry.msgid_plural:
|
||||
_apply_plural_translation(entry, translation)
|
||||
else:
|
||||
entry.msgstr = translation
|
||||
|
||||
if mark_fuzzy and "fuzzy" not in entry.flags:
|
||||
entry.flags.append("fuzzy")
|
||||
|
||||
refs = item["context_langs"]
|
||||
refs_tag = f" [refs: {', '.join(refs)}]" if refs else " [no refs]"
|
||||
attribution = f"Machine-translated via backfill_po.py ({model}){refs_tag}"
|
||||
if entry.tcomment:
|
||||
if attribution not in entry.tcomment:
|
||||
entry.tcomment = f"{entry.tcomment}\n{attribution}"
|
||||
else:
|
||||
entry.tcomment = attribution
|
||||
|
||||
|
||||
def _build_batch_items(
|
||||
entries: list[polib.POEntry],
|
||||
index: dict[str, Any],
|
||||
lang: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert a list of POEntries into the dict format used by translate_batch."""
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
if entry.msgid_plural:
|
||||
item: dict[str, Any] = {
|
||||
"msgid": entry.msgid,
|
||||
"msgid_plural": entry.msgid_plural,
|
||||
"index_key": _plural_key(entry.msgid, entry.msgid_plural),
|
||||
"is_plural": True,
|
||||
}
|
||||
else:
|
||||
item = {
|
||||
"msgid": entry.msgid,
|
||||
"index_key": entry.msgid,
|
||||
"is_plural": False,
|
||||
}
|
||||
item["context_langs"] = _context_langs(item, index, lang)
|
||||
item["context_count"] = len(item["context_langs"])
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _process_batches(
|
||||
missing: list[polib.POEntry],
|
||||
index: dict[str, Any],
|
||||
lang: str,
|
||||
batch_size: int,
|
||||
model: str,
|
||||
dry_run: bool,
|
||||
mark_fuzzy: bool,
|
||||
cat: polib.POFile | None = None,
|
||||
po_path: Path | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Translate missing entries in batches. Returns (translated, failed) counts.
|
||||
|
||||
When ``cat`` and ``po_path`` are provided and ``dry_run`` is False, the
|
||||
catalog is saved to disk after each batch that produced at least one
|
||||
successful translation. This means a crash mid-run only loses the in-flight
|
||||
batch rather than every batch translated so far.
|
||||
"""
|
||||
translated_count = 0
|
||||
failed_count = 0
|
||||
for batch_start in range(0, len(missing), batch_size):
|
||||
batch_entries = missing[batch_start : batch_start + batch_size]
|
||||
batch_items = _build_batch_items(batch_entries, index, lang)
|
||||
end = min(batch_start + batch_size, len(missing))
|
||||
print(
|
||||
f" Translating entries {batch_start + 1}–{end} of {len(missing)} …",
|
||||
file=sys.stderr,
|
||||
)
|
||||
try:
|
||||
translations = translate_batch(model, lang, batch_items, index)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
print(f" ERROR in batch starting at {batch_start}: {exc}", file=sys.stderr)
|
||||
failed_count += len(batch_entries)
|
||||
continue
|
||||
batch_applied = 0
|
||||
for i, entry in enumerate(batch_entries):
|
||||
translation = translations.get(i)
|
||||
if translation is None:
|
||||
print(
|
||||
f" WARNING: no translation returned for index {i} "
|
||||
f"(msgid: {entry.msgid[:60]!r})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
if dry_run:
|
||||
ctx = batch_items[i]["context_count"]
|
||||
ctx_tag = f" [ctx:{ctx}]" if ctx < 3 else ""
|
||||
print(
|
||||
f" [{lang}]{ctx_tag} {entry.msgid[:60]!r} → {translation[:60]!r}"
|
||||
)
|
||||
else:
|
||||
_apply_translation(
|
||||
entry, translation, batch_items[i], model, mark_fuzzy
|
||||
)
|
||||
batch_applied += 1
|
||||
translated_count += 1
|
||||
if (
|
||||
not dry_run
|
||||
and batch_applied > 0
|
||||
and cat is not None
|
||||
and po_path is not None
|
||||
):
|
||||
cat.save()
|
||||
print(
|
||||
f" Saved {po_path} ({batch_applied} entry(ies) in this batch).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return translated_count, failed_count
|
||||
|
||||
|
||||
def backfill(
|
||||
lang: str,
|
||||
*,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
limit: int | None = None,
|
||||
min_context: int = 0,
|
||||
model: str = DEFAULT_MODEL,
|
||||
index_path: Path = DEFAULT_INDEX,
|
||||
dry_run: bool = False,
|
||||
mark_fuzzy: bool = True,
|
||||
) -> None:
|
||||
"""Backfill missing translations in the target language's .po file."""
|
||||
# Defense against path traversal: ``lang`` lands in a filesystem path
|
||||
# without further sanitization, so reject anything that isn't an
|
||||
# ISO 639-1/639-2 code with an optional ISO 3166 region (e.g. ``pt_BR``).
|
||||
if not re.fullmatch(r"[a-z]{2,3}(_[A-Z]{2})?", lang):
|
||||
print(
|
||||
f"Invalid language code: {lang!r} "
|
||||
"(expected ISO 639 code, optionally with _<REGION>, e.g. 'fr' or 'pt_BR')",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
po_path = TRANSLATIONS_DIR / lang / "LC_MESSAGES" / "messages.po"
|
||||
if not po_path.exists():
|
||||
print(f"No .po file found for language '{lang}': {po_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not index_path.exists():
|
||||
print(
|
||||
f"Translation index not found at {index_path}.\n"
|
||||
"Run: python scripts/translations/build_translation_index.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print("Loading translation index …", file=sys.stderr)
|
||||
with open(index_path, encoding="utf-8") as f:
|
||||
index: dict[str, Any] = json.load(f)
|
||||
|
||||
print(f"Loading {po_path} …", file=sys.stderr)
|
||||
cat = polib.pofile(str(po_path))
|
||||
|
||||
missing: list[polib.POEntry] = [e for e in cat if e.msgid and _is_missing(e)]
|
||||
print(f"Found {len(missing)} untranslated entries for '{lang}'.", file=sys.stderr)
|
||||
|
||||
if min_context > 0:
|
||||
before = len(missing)
|
||||
missing = [
|
||||
e
|
||||
for e in missing
|
||||
if _context_count(
|
||||
{
|
||||
"index_key": (
|
||||
_plural_key(e.msgid, e.msgid_plural)
|
||||
if e.msgid_plural
|
||||
else e.msgid
|
||||
)
|
||||
},
|
||||
index,
|
||||
lang,
|
||||
)
|
||||
>= min_context
|
||||
]
|
||||
skipped = before - len(missing)
|
||||
print(
|
||||
f"Skipping {skipped} entries with fewer than {min_context} reference "
|
||||
f"translation(s) (use --min-context 0 to include them).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if limit is not None:
|
||||
missing = missing[:limit]
|
||||
print(f"Limiting to {limit} entries.", file=sys.stderr)
|
||||
|
||||
if not missing:
|
||||
print("Nothing to do.", file=sys.stderr)
|
||||
return
|
||||
|
||||
translated_count, failed_count = _process_batches(
|
||||
missing,
|
||||
index,
|
||||
lang,
|
||||
batch_size,
|
||||
model,
|
||||
dry_run,
|
||||
mark_fuzzy,
|
||||
cat=cat,
|
||||
po_path=po_path,
|
||||
)
|
||||
|
||||
print(
|
||||
f"\nDone. Translated: {translated_count}, Failed/skipped: {failed_count}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not dry_run and translated_count > 0:
|
||||
print(
|
||||
f"Translations written to {po_path} (marked #, fuzzy for review).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse CLI arguments and run translation backfill."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Backfill missing .po translations using Claude AI",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lang", required=True, help="ISO language code (e.g. fr, de, ja)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=DEFAULT_BATCH_SIZE,
|
||||
help=f"Strings per Claude request (default: {DEFAULT_BATCH_SIZE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum number of entries to translate (default: unlimited)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"Claude model ID (default: {DEFAULT_MODEL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
type=Path,
|
||||
default=DEFAULT_INDEX,
|
||||
help=f"Path to translation_index.json (default: {DEFAULT_INDEX})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print translations without modifying the .po file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-context",
|
||||
type=int,
|
||||
default=0,
|
||||
metavar="N",
|
||||
help=(
|
||||
"Skip entries with fewer than N reference translations in other languages "
|
||||
"(default: 0 = translate everything). Strings with low context are more "
|
||||
"likely to be ambiguous single words or fragments — set to e.g. 2 to only "
|
||||
"translate strings that have been confirmed in at least 2 other languages."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-fuzzy",
|
||||
dest="mark_fuzzy",
|
||||
action="store_false",
|
||||
default=True,
|
||||
help=(
|
||||
"Do not mark generated translations as #, fuzzy. "
|
||||
"WARNING: fuzzy entries are excluded from compiled .mo files. "
|
||||
"Removing this flag causes AI-generated translations to be served "
|
||||
"to end users without human review — only use after you have "
|
||||
"manually verified the .po file."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
backfill(
|
||||
lang=args.lang,
|
||||
batch_size=args.batch_size,
|
||||
limit=args.limit,
|
||||
min_context=args.min_context,
|
||||
model=args.model,
|
||||
index_path=args.index,
|
||||
dry_run=args.dry_run,
|
||||
mark_fuzzy=args.mark_fuzzy,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
153
scripts/translations/build_translation_index.py
Normal file
153
scripts/translations/build_translation_index.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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.
|
||||
"""Build a cross-language translation index from all .po files.
|
||||
|
||||
Outputs a JSON file structured as:
|
||||
{
|
||||
"<msgid>": {
|
||||
"<lang>": "<translated string or null>",
|
||||
...
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
For plural entries the key is "<msgid>\x00<msgid_plural>" and the value
|
||||
is a dict mapping lang -> {0: "...", 1: "..."} (or null if untranslated).
|
||||
|
||||
Usage:
|
||||
python scripts/translations/build_translation_index.py
|
||||
python scripts/translations/build_translation_index.py \
|
||||
--translations-dir superset/translations \
|
||||
--output /tmp/translation_index.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import polib # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
print("polib is required. Install with: pip install polib", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
|
||||
DEFAULT_OUTPUT = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "superset"
|
||||
/ "translations"
|
||||
/ "translation_index.json"
|
||||
)
|
||||
|
||||
|
||||
def _is_translated(entry: polib.POEntry) -> bool:
|
||||
"""Return True if the entry has a non-empty, non-fuzzy translation."""
|
||||
if "fuzzy" in entry.flags:
|
||||
return False
|
||||
if entry.msgid_plural:
|
||||
return any(v for v in entry.msgstr_plural.values())
|
||||
return bool(entry.msgstr)
|
||||
|
||||
|
||||
def _plural_key(entry: polib.POEntry) -> str:
|
||||
"""Build the combined key used for plural translation entries."""
|
||||
return f"{entry.msgid}\x00{entry.msgid_plural}"
|
||||
|
||||
|
||||
def build_index(translations_dir: Path) -> dict[str, Any]:
|
||||
"""Read all .po files and build a combined translation index."""
|
||||
index: dict[str, dict[str, Any]] = {}
|
||||
|
||||
langs = sorted(
|
||||
d
|
||||
for d in os.listdir(translations_dir)
|
||||
if (translations_dir / d / "LC_MESSAGES" / "messages.po").exists()
|
||||
and d != "en" # en has empty msgstr by convention (source = target)
|
||||
)
|
||||
|
||||
for lang in langs:
|
||||
po_path = translations_dir / lang / "LC_MESSAGES" / "messages.po"
|
||||
cat = polib.pofile(str(po_path))
|
||||
for entry in cat:
|
||||
if not entry.msgid:
|
||||
continue # skip header entry
|
||||
|
||||
if entry.msgid_plural:
|
||||
key = _plural_key(entry)
|
||||
if key not in index:
|
||||
index[key] = {}
|
||||
# Fuzzy entries are unreviewed (often machine-generated drafts),
|
||||
# so excluding them prevents feeding unverified translations
|
||||
# back into the AI backfill prompt as trusted context.
|
||||
index[key][lang] = (
|
||||
dict(entry.msgstr_plural) if _is_translated(entry) else None
|
||||
)
|
||||
else:
|
||||
key = entry.msgid
|
||||
if key not in index:
|
||||
index[key] = {}
|
||||
index[key][lang] = entry.msgstr if _is_translated(entry) else None
|
||||
|
||||
# Ensure every entry has a slot for every language (null if missing)
|
||||
for key in index:
|
||||
for lang in langs:
|
||||
index[key].setdefault(lang, None)
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse arguments, build the translation index, and write it to disk."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build cross-language translation index"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--translations-dir",
|
||||
type=Path,
|
||||
default=TRANSLATIONS_DIR,
|
||||
help="Path to the translations directory (default: superset/translations)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=DEFAULT_OUTPUT,
|
||||
help=(
|
||||
"Output JSON file path"
|
||||
" (default: superset/translations/translation_index.json)"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Reading .po files from {args.translations_dir} …", file=sys.stderr)
|
||||
index = build_index(args.translations_dir)
|
||||
print(f"Indexed {len(index)} message IDs.", file=sys.stderr)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"Written to {args.output}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
250
scripts/translations/check_translation_regression.py
Executable file
250
scripts/translations/check_translation_regression.py
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
"""
|
||||
Check that source-code changes don't cause translation regressions.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Count non-fuzzy translated entries in all .po files and write JSON to stdout:
|
||||
|
||||
python check_translation_regression.py --count
|
||||
|
||||
Compare the current .po state against a previously-recorded baseline and fail
|
||||
if any language lost translations:
|
||||
|
||||
python check_translation_regression.py --compare /path/to/before.json
|
||||
|
||||
Optionally write a markdown report to a file (used by CI to post a PR comment):
|
||||
|
||||
python check_translation_regression.py --compare before.json --report report.md
|
||||
|
||||
Use a translations directory other than the repo default (used by CI to count
|
||||
against a separate base-branch worktree):
|
||||
|
||||
python check_translation_regression.py --count \\
|
||||
--translations-dir /tmp/base-worktree/superset/translations
|
||||
|
||||
Typical CI workflow
|
||||
-------------------
|
||||
1. Create a base-branch worktree alongside the PR worktree
|
||||
2. Run babel_update.sh in the base worktree (extract from BASE source)
|
||||
3. Record baseline: python ... --count --translations-dir BASE_TREE > before.json
|
||||
4. Run babel_update.sh in the PR worktree (extract from PR source) starting
|
||||
from the same pristine BASE translations
|
||||
5. Compare: python ... --compare before.json [--report report.md]
|
||||
|
||||
Comparing two babel_update outputs that started from the same BASE .po files
|
||||
isolates regressions caused by the PR's source diff from any pre-existing
|
||||
drift on the base branch.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_TRANSLATIONS_DIR = (
|
||||
Path(__file__).resolve().parent.parent.parent / "superset" / "translations"
|
||||
)
|
||||
|
||||
# English .po files use empty msgstr by convention (source language == target),
|
||||
# so they always show 0 translated entries and should not be checked.
|
||||
SKIP_LANGS = {"en"}
|
||||
|
||||
|
||||
def count_translated(po_file: Path) -> int:
|
||||
"""Return the number of non-fuzzy translated messages in a .po file.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: if ``msgfmt`` fails (e.g. malformed
|
||||
.po file). The regression check exists to surface translation
|
||||
problems, so a silent zero would defeat its purpose — let the
|
||||
caller see a malformed file as a hard failure.
|
||||
"""
|
||||
import shutil # noqa: PLC0415
|
||||
|
||||
msgfmt = shutil.which("msgfmt") or "msgfmt"
|
||||
result = subprocess.run( # noqa: S603
|
||||
[msgfmt, "--statistics", "-o", "/dev/null", str(po_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
# stderr: "123 translated messages, 4 fuzzy translations, 56 untranslated messages."
|
||||
match = re.search(r"(\d+) translated message", result.stderr)
|
||||
if not match:
|
||||
raise RuntimeError(
|
||||
f"Could not parse msgfmt --statistics output for {po_file}: "
|
||||
f"{result.stderr!r}"
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def get_counts(translations_dir: Path) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")):
|
||||
lang = po_file.parent.parent.name
|
||||
if lang in SKIP_LANGS:
|
||||
continue
|
||||
try:
|
||||
counts[lang] = count_translated(po_file)
|
||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
# A malformed .po file (msgfmt non-zero exit, or stderr we
|
||||
# can't parse) is a real problem worth seeing, but it shouldn't
|
||||
# take the whole regression check down with it — that would
|
||||
# hide every other language's status. Skip and warn instead;
|
||||
# the missing lang will not appear in the comparison output.
|
||||
print(
|
||||
f"WARNING: skipping {lang} — {po_file} could not be counted: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
|
||||
"""Build a markdown report for posting as a PR comment."""
|
||||
rows = "\n".join(
|
||||
f"| `{lang}` | {b} | {a} | -{b - a} |" for lang, b, a in regressions
|
||||
)
|
||||
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
|
||||
return (
|
||||
"## ⚠️ Translation Regression Detected\n\n"
|
||||
f"This PR causes existing translations to become fuzzy or be removed "
|
||||
f"in {affected}. Please fix the affected `.po` files before merging.\n\n"
|
||||
"| Language | Before | After | Lost |\n"
|
||||
"|----------|-------:|------:|-----:|\n"
|
||||
f"{rows}\n\n"
|
||||
"### How to fix\n\n"
|
||||
"**1. Install dependencies** (if not already set up):\n\n"
|
||||
"```bash\n"
|
||||
"pip install -r superset/translations/requirements.txt\n"
|
||||
"sudo apt-get install gettext # or: brew install gettext\n"
|
||||
"```\n\n"
|
||||
"**2. Re-extract strings and sync `.po` files:**\n\n"
|
||||
"```bash\n"
|
||||
"./scripts/translations/babel_update.sh\n"
|
||||
"```\n\n"
|
||||
"This rewrites `superset/translations/messages.pot` from the current "
|
||||
"source files and merges the changes into every `.po` file. Strings "
|
||||
"whose `msgid` changed will be marked `#, fuzzy`.\n\n"
|
||||
f"**3. Resolve the fuzzy entries** in the affected language files "
|
||||
f"({affected}):\n\n"
|
||||
"```bash\n"
|
||||
"grep -n '#, fuzzy' superset/translations/<lang>/LC_MESSAGES/messages.po\n"
|
||||
"```\n\n"
|
||||
"For each fuzzy entry, either rewrite the `msgstr` to match the new "
|
||||
"string and remove the `#, fuzzy` line, or clear the `msgstr` to "
|
||||
'`""` if you cannot provide a translation.\n\n'
|
||||
"**4. Commit your changes to the `.po` files.**\n"
|
||||
)
|
||||
|
||||
|
||||
def cmd_count(translations_dir: Path) -> None:
|
||||
counts = get_counts(translations_dir)
|
||||
print(json.dumps(counts, indent=2))
|
||||
|
||||
|
||||
def cmd_compare(
|
||||
before_path: str,
|
||||
translations_dir: Path,
|
||||
report_path: Optional[str] = None,
|
||||
) -> None:
|
||||
with open(before_path) as f:
|
||||
before: dict[str, int] = json.load(f)
|
||||
|
||||
after = get_counts(translations_dir)
|
||||
|
||||
regressions: list[tuple[str, int, int]] = []
|
||||
for lang, before_count in sorted(before.items()):
|
||||
after_count = after.get(lang, 0)
|
||||
if after_count < before_count:
|
||||
regressions.append((lang, before_count, after_count))
|
||||
|
||||
if regressions:
|
||||
print("Translation regression detected!\n")
|
||||
for lang, b, a in regressions:
|
||||
lost = b - a
|
||||
print(f" {lang}: {b} -> {a} (-{lost} string(s) became fuzzy or removed)")
|
||||
print(
|
||||
"\nStrings renamed or deleted by this PR invalidated existing translations."
|
||||
)
|
||||
print(
|
||||
"Update the affected .po files to restore the lost entries before merging."
|
||||
)
|
||||
if report_path:
|
||||
Path(report_path).write_text(
|
||||
build_regression_report(regressions), encoding="utf-8"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# All good — print a summary so it's easy to read in CI logs.
|
||||
print("No translation regressions.\n")
|
||||
for lang in sorted(after):
|
||||
b = before.get(lang, 0)
|
||||
a = after[lang]
|
||||
if a > b:
|
||||
delta = f"+{a - b}"
|
||||
elif a == b:
|
||||
delta = "no change"
|
||||
else:
|
||||
delta = f"-{b - a}"
|
||||
print(f" {lang}: {b} -> {a} ({delta})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check for translation regressions in .po files."
|
||||
)
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument(
|
||||
"--count",
|
||||
action="store_true",
|
||||
help="Output translation counts per language as JSON.",
|
||||
)
|
||||
action.add_argument(
|
||||
"--compare",
|
||||
metavar="BEFORE_JSON",
|
||||
help="Compare current counts against a baseline JSON file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report",
|
||||
metavar="REPORT_MD",
|
||||
help="When --compare detects regressions, write a markdown report here.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--translations-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_TRANSLATIONS_DIR,
|
||||
help=(
|
||||
"Path to the translations directory containing per-language "
|
||||
"LC_MESSAGES/messages.po files (default: <repo>/superset/translations)."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.count:
|
||||
cmd_count(args.translations_dir)
|
||||
else:
|
||||
cmd_compare(args.compare, args.translations_dir, args.report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"pydantic>=2.8.0",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
"sqlglot>=30.8.0, <31",
|
||||
"typing-extensions>=4.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
waitForChartLoad,
|
||||
ChartSpec,
|
||||
getChartAliasesBySpec,
|
||||
} from 'cypress/utils';
|
||||
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { WORLD_HEALTH_CHARTS } from './utils';
|
||||
import { isLegacyResponse } from '../../utils/vizPlugins';
|
||||
|
||||
describe('Dashboard top-level controls', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
});
|
||||
|
||||
// flaky test - query completes before assertion
|
||||
it.skip('should allow chart level refresh', () => {
|
||||
const mapSpec = WORLD_HEALTH_CHARTS.find(
|
||||
({ viz }) => viz === 'world_map',
|
||||
) as ChartSpec;
|
||||
waitForChartLoad(mapSpec).then(gridComponent => {
|
||||
const mapId = gridComponent.attr('data-test-chart-id');
|
||||
cy.get('[data-test="grid-container"]').find('.world_map').should('exist');
|
||||
cy.get(`#slice_${mapId}-controls`).click();
|
||||
cy.get(`[data-test="slice_${mapId}-menu"]`)
|
||||
.find('[data-test="refresh-chart-menu-item"]')
|
||||
.click({ force: true });
|
||||
// likely cause for flakiness:
|
||||
// The query completes before this assertion happens.
|
||||
// Solution: pause the network before clicking, assert, then unpause network.
|
||||
cy.get('[data-test="refresh-chart-menu-item"]').should(
|
||||
'have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
waitForChartLoad(mapSpec);
|
||||
cy.get('[data-test="refresh-chart-menu-item"]').should(
|
||||
'not.have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow dashboard level force refresh', () => {
|
||||
// when charts are not start loading, for example, under a secondary tab,
|
||||
// should allow force refresh
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
getChartAliasesBySpec(WORLD_HEALTH_CHARTS).then(aliases => {
|
||||
cy.get('[aria-label="ellipsis"]').click();
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
|
||||
'not.have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').click({
|
||||
force: true,
|
||||
});
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
|
||||
'have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
|
||||
// wait all charts force refreshed.
|
||||
|
||||
cy.wait(aliases).then(xhrs => {
|
||||
xhrs.forEach(async ({ response, request }) => {
|
||||
const responseBody = response?.body;
|
||||
const isCached = isLegacyResponse(responseBody)
|
||||
? responseBody.is_cached
|
||||
: responseBody.result[0].is_cached;
|
||||
// request url should indicate force-refresh operation
|
||||
expect(request.url).to.have.string('force=true');
|
||||
// is_cached in response should be false
|
||||
expect(isCached).to.equal(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
cy.get('[aria-label="ellipsis"]').click();
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').and(
|
||||
'not.have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* 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 { nativeFilters } from 'cypress/support/directories';
|
||||
|
||||
import {
|
||||
addCountryNameFilter,
|
||||
applyNativeFilterValueWithIndex,
|
||||
enterNativeFilterEditModal,
|
||||
inputNativeFilterDefaultValue,
|
||||
saveNativeFilterSettings,
|
||||
validateFilterNameOnDashboard,
|
||||
testItems,
|
||||
interceptFilterState,
|
||||
} from './utils';
|
||||
import {
|
||||
prepareDashboardFilters,
|
||||
SAMPLE_CHART,
|
||||
visitDashboard,
|
||||
} from './shared_dashboard_functions';
|
||||
|
||||
function openMoreFilters(waitFilterState = true) {
|
||||
interceptFilterState();
|
||||
// Wait for the dropdown button to appear when filters are overflowed
|
||||
// The button only appears when there are overflowed filters
|
||||
cy.getBySel('dropdown-container-btn', { timeout: 10000 })
|
||||
.should('exist')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
|
||||
if (waitFilterState) {
|
||||
cy.wait('@postFilterState');
|
||||
}
|
||||
}
|
||||
|
||||
function openVerticalFilterBar() {
|
||||
cy.getBySel('dashboard-filters-panel').should('exist');
|
||||
cy.getBySel('filter-bar__expand-button').click();
|
||||
}
|
||||
|
||||
function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') {
|
||||
cy.getBySel('filterbar-orientation-icon').click();
|
||||
cy.wait(250);
|
||||
cy.get('.filter-bar-orientation-submenu')
|
||||
.contains('Orientation of filter bar')
|
||||
.should('exist')
|
||||
.trigger('mouseover');
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
cy.get('.ant-dropdown-menu-item-selected')
|
||||
.contains('Horizontal (Top)')
|
||||
.should('exist');
|
||||
cy.get('.ant-dropdown-menu-item').contains('Vertical (Left)').click();
|
||||
cy.getBySel('dashboard-filters-panel').should('exist');
|
||||
} else {
|
||||
cy.get('.ant-dropdown-menu-item-selected')
|
||||
.contains('Vertical (Left)')
|
||||
.should('exist');
|
||||
cy.get('.ant-dropdown-menu-item').contains('Horizontal (Top)').click();
|
||||
cy.getBySel('loading-indicator').should('exist');
|
||||
cy.getBySel('filter-bar').should('exist');
|
||||
cy.getBySel('dashboard-filters-panel').should('not.exist');
|
||||
}
|
||||
}
|
||||
|
||||
describe('Horizontal FilterBar', () => {
|
||||
it('should go from vertical to horizontal and the opposite', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
setFilterBarOrientation('vertical');
|
||||
});
|
||||
|
||||
it('should show all default actions in horizontal mode', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
cy.getBySel('horizontal-filterbar-empty')
|
||||
.contains('No filters are currently added to this dashboard.')
|
||||
.should('exist');
|
||||
cy.get(nativeFilters.filtersPanel.filterGear).click({
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu').should('be.visible');
|
||||
cy.getBySel('filter-bar__create-filter').should('exist');
|
||||
cy.getBySel('filterbar-action-buttons').should('exist');
|
||||
});
|
||||
|
||||
it('should stay in horizontal mode when reloading', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
cy.reload();
|
||||
cy.getBySel('dashboard-filters-panel').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show all filters in available space on load', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
cy.get('.filter-item-wrapper').should('have.length', 3);
|
||||
});
|
||||
|
||||
it('should show "more filters" on window resizing up and down', () => {
|
||||
// Use 4 filters with unique columns to ensure overflow testing while allowing all to fit at large viewport
|
||||
prepareDashboardFilters([
|
||||
{ name: 'Country', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'Code', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'Region', column: 'region', datasetId: 2 },
|
||||
{ name: 'Year', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
|
||||
// At full width, check how many filters are visible in main bar
|
||||
cy.get('.filter-item-wrapper').then($items => {
|
||||
cy.log(`Found ${$items.length} filter items at full width`);
|
||||
});
|
||||
|
||||
// Resize to force overflow
|
||||
cy.viewport(500, 1024);
|
||||
cy.wait(500); // Allow layout to stabilize after viewport change
|
||||
|
||||
// Should have some filters visible and dropdown button present
|
||||
cy.get('.filter-item-wrapper').should('have.length.lessThan', 4);
|
||||
cy.getBySel('dropdown-container-btn').should('exist');
|
||||
|
||||
// Open more filters and verify all are accessible in the dropdown
|
||||
openMoreFilters(false);
|
||||
// Check that the dropdown content contains filters
|
||||
cy.getBySel('dropdown-content').within(() => {
|
||||
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
|
||||
});
|
||||
|
||||
// Close the dropdown
|
||||
cy.getBySel('filter-bar').click();
|
||||
|
||||
// Test with medium viewport
|
||||
cy.viewport(800, 1024);
|
||||
cy.wait(500); // Allow layout to stabilize after viewport change
|
||||
|
||||
// May or may not have overflow at this size - test adaptively
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="dropdown-container-btn"]').length > 0) {
|
||||
openMoreFilters(false);
|
||||
cy.getBySel('dropdown-content').within(() => {
|
||||
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
|
||||
});
|
||||
cy.getBySel('filter-bar').click(); // Close dropdown
|
||||
}
|
||||
});
|
||||
|
||||
// At large viewport, all filters should fit
|
||||
cy.viewport(1300, 1024);
|
||||
cy.wait(500); // Allow layout to stabilize after viewport change
|
||||
cy.get('.filter-item-wrapper').then($items => {
|
||||
cy.log(`Found ${$items.length} filter items at large width`);
|
||||
// Just verify we have some filters, don't assert exact count
|
||||
expect($items.length).to.be.greaterThan(0);
|
||||
});
|
||||
cy.getBySel('dropdown-container-btn').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show "more filters" and scroll', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_4', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_5', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_6', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_7', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_8', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_9', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_10', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_11', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_12', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
|
||||
cy.get('.filter-item-wrapper').should('have.length', 4);
|
||||
openMoreFilters();
|
||||
cy.getBySel('form-item-value').should('have.length', 12);
|
||||
cy.getBySel('filter-control-name').contains('test_3').should('be.visible');
|
||||
cy.getBySel('filter-control-name')
|
||||
.contains('test_12')
|
||||
.should('not.be.visible');
|
||||
cy.getBySel('filter-control-name').contains('test_12').scrollIntoView();
|
||||
cy.getBySel('filter-control-name').contains('test_12').should('be.visible');
|
||||
});
|
||||
|
||||
it('should display newly added filter', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
|
||||
enterNativeFilterEditModal(false);
|
||||
addCountryNameFilter();
|
||||
saveNativeFilterSettings([]);
|
||||
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
|
||||
});
|
||||
|
||||
it.skip('should spot changes in "more filters" and apply their values', () => {
|
||||
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_4', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_5', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_6', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_7', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_8', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_9', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_10', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_11', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_12', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
openMoreFilters();
|
||||
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
|
||||
cy.get(nativeFilters.applyFilter).click({ force: true });
|
||||
cy.wait('@chart');
|
||||
cy.get('.ant-scroll-number.ant-badge-count').should(
|
||||
'have.attr',
|
||||
'title',
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
it.skip('should focus filter and open "more filters" programmatically', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_4', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_5', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_6', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_7', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_8', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_9', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_10', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_11', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_12', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
openMoreFilters();
|
||||
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
|
||||
cy.get(nativeFilters.applyFilter).click({ force: true });
|
||||
cy.getBySel('slice-header').within(() => {
|
||||
cy.get('.filter-counts').trigger('mouseover');
|
||||
});
|
||||
cy.getBySel('filter-status-popover').contains('test_9').click();
|
||||
cy.getBySel('dropdown-content').should('be.visible');
|
||||
cy.get('.ant-select-focused').should('be.visible');
|
||||
});
|
||||
|
||||
it.skip('should show tag count and one plain tag on focus and only count on blur in select ', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue('Albania');
|
||||
cy.get('.ant-select-selection-search-input').clear({ force: true });
|
||||
inputNativeFilterDefaultValue('Algeria', true);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.getBySel('filter-bar').within(() => {
|
||||
cy.get(nativeFilters.filterItem).contains('Albania').should('be.visible');
|
||||
cy.get(nativeFilters.filterItem).contains('+ 1 ...').should('be.visible');
|
||||
cy.get('.ant-select-selection-search-input').click();
|
||||
cy.get(nativeFilters.filterItem).contains('+ 2 ...').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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 qs from 'querystringify';
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { WORLD_HEALTH_CHARTS } from './utils';
|
||||
|
||||
interface QueryString {
|
||||
native_filters_key: string;
|
||||
}
|
||||
|
||||
describe('nativefilter url param key', () => {
|
||||
// const urlParams = { param1: '123', param2: 'abc' };
|
||||
|
||||
let initialFilterKey: string;
|
||||
it('should have cachekey in nativefilter param', () => {
|
||||
// things in `before` will not retry and the `waitForChartLoad` check is
|
||||
// especially flaky and may need more retries
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
cy.wait(1000); // wait for key to be published (debounced)
|
||||
cy.location().then(loc => {
|
||||
const queryParams = qs.parse(loc.search) as QueryString;
|
||||
expect(typeof queryParams.native_filters_key).eq('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have different key when page reloads', () => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
cy.wait(1000); // wait for key to be published (debounced)
|
||||
cy.location().then(loc => {
|
||||
const queryParams = qs.parse(loc.search) as QueryString;
|
||||
expect(queryParams.native_filters_key).not.equal(initialFilterKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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 { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import { WORLD_HEALTH_CHARTS, interceptLog } from './utils';
|
||||
|
||||
describe('Dashboard load', () => {
|
||||
it('should load dashboard', () => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it('should load in edit mode', () => {
|
||||
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
|
||||
cy.getBySel('discard-changes-button').should('be.visible');
|
||||
});
|
||||
|
||||
it('should load in standalone mode', () => {
|
||||
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
|
||||
cy.get('#app-menu').should('not.exist');
|
||||
});
|
||||
|
||||
it('should load in edit/standalone mode', () => {
|
||||
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
|
||||
cy.getBySel('discard-changes-button').should('be.visible');
|
||||
cy.get('#app-menu').should('not.exist');
|
||||
});
|
||||
|
||||
// TODO flaky test. skipping to unblock CI
|
||||
it.skip('should send log data', () => {
|
||||
interceptLog();
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
cy.wait('@logs', { timeout: 15000 });
|
||||
});
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
*
|
||||
* 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 qs from 'querystring';
|
||||
import {
|
||||
dashboardView,
|
||||
nativeFilters,
|
||||
dataTestChartName,
|
||||
} from 'cypress/support/directories';
|
||||
|
||||
import {
|
||||
addCountryNameFilter,
|
||||
applyAdvancedTimeRangeFilterOnDashboard,
|
||||
applyNativeFilterValueWithIndex,
|
||||
cancelNativeFilterSettings,
|
||||
deleteNativeFilter,
|
||||
enterNativeFilterEditModal,
|
||||
fillNativeFilterForm,
|
||||
inputNativeFilterDefaultValue,
|
||||
saveNativeFilterSettings,
|
||||
undoDeleteNativeFilter,
|
||||
validateFilterContentOnDashboard,
|
||||
validateFilterNameOnDashboard,
|
||||
testItems,
|
||||
WORLD_HEALTH_CHARTS,
|
||||
} from './utils';
|
||||
import {
|
||||
prepareDashboardFilters,
|
||||
SAMPLE_CHART,
|
||||
visitDashboard,
|
||||
} from './shared_dashboard_functions';
|
||||
|
||||
// function selectFilter(index: number) {
|
||||
// cy.get("[data-test='filter-title-container'] [draggable='true']")
|
||||
// .eq(index)
|
||||
// .click();
|
||||
// }
|
||||
|
||||
// function closeFilterModal() {
|
||||
// cy.get('body').then($body => {
|
||||
// if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
|
||||
// cy.getBySel('native-filter-modal-cancel-button').click();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
describe('Native filters', () => {
|
||||
describe('Nativefilters initial state not required', () => {
|
||||
it("User can check 'Filter has default value'", () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
|
||||
});
|
||||
|
||||
it('User can add a new native filter', () => {
|
||||
prepareDashboardFilters([]);
|
||||
|
||||
let filterKey: string;
|
||||
const removeFirstChar = (search: string) =>
|
||||
search.split('').slice(1, search.length).join('');
|
||||
|
||||
cy.location().then(loc => {
|
||||
cy.url().should('contain', 'native_filters_key');
|
||||
const queryParams = qs.parse(removeFirstChar(loc.search));
|
||||
filterKey = queryParams.native_filters_key as string;
|
||||
expect(typeof filterKey).eq('string');
|
||||
});
|
||||
enterNativeFilterEditModal();
|
||||
addCountryNameFilter();
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.location().then(loc => {
|
||||
cy.url().should('contain', 'native_filters_key');
|
||||
const queryParams = qs.parse(removeFirstChar(loc.search));
|
||||
const newfilterKey = queryParams.native_filters_key;
|
||||
expect(newfilterKey).eq(filterKey);
|
||||
});
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
});
|
||||
|
||||
it('User can restore a deleted native filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_code', column: 'country_code', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
cy.get('[data-test="restore-filter-button"]')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(nativeFilters.modal.container)
|
||||
.find(nativeFilters.filtersPanel.filterName)
|
||||
.should(
|
||||
'have.attr',
|
||||
'value',
|
||||
testItems.topTenChart.filterColumnCountryCode,
|
||||
);
|
||||
});
|
||||
|
||||
it('User can create a time grain filter', () => {
|
||||
prepareDashboardFilters([]);
|
||||
enterNativeFilterEditModal();
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.timeGrain,
|
||||
testItems.filterType.timeGrain,
|
||||
testItems.datasetForNativeFilter,
|
||||
);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
applyNativeFilterValueWithIndex(0, testItems.filterTimeGrain);
|
||||
cy.get(nativeFilters.applyFilter).click();
|
||||
cy.url().then(u => {
|
||||
const ur = new URL(u);
|
||||
expect(ur.search).to.include('native_filters');
|
||||
});
|
||||
validateFilterNameOnDashboard(testItems.filterType.timeGrain);
|
||||
validateFilterContentOnDashboard(testItems.filterTimeGrain);
|
||||
});
|
||||
|
||||
it.skip('User can create a time range filter', () => {
|
||||
enterNativeFilterEditModal();
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.timeRange,
|
||||
testItems.filterType.timeRange,
|
||||
);
|
||||
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
|
||||
cy.get(dashboardView.salesDashboardSpecific.vehicleSalesFilterTimeRange)
|
||||
.should('be.visible')
|
||||
.click();
|
||||
applyAdvancedTimeRangeFilterOnDashboard('2005-12-17', '2006-12-17');
|
||||
cy.url().then(u => {
|
||||
const ur = new URL(u);
|
||||
expect(ur.search).to.include('native_filters');
|
||||
});
|
||||
validateFilterNameOnDashboard(testItems.filterType.timeRange);
|
||||
cy.get(nativeFilters.filterFromDashboardView.timeRangeFilterContent)
|
||||
.contains('2005-12-17')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
it.skip('User can create a time column filter', () => {
|
||||
enterNativeFilterEditModal();
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.timeColumn,
|
||||
testItems.filterType.timeColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
);
|
||||
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
|
||||
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
// assert that native filter is created
|
||||
validateFilterNameOnDashboard(testItems.filterType.timeColumn);
|
||||
applyNativeFilterValueWithIndex(
|
||||
0,
|
||||
testItems.topTenChart.filterColumnYear,
|
||||
);
|
||||
cy.get(nativeFilters.applyFilter).click({ force: true });
|
||||
cy.wait('@chart');
|
||||
validateFilterContentOnDashboard(testItems.topTenChart.filterColumnYear);
|
||||
});
|
||||
|
||||
describe.only('Numerical Range Filter - Display Modes', () => {
|
||||
beforeEach(() => {
|
||||
visitDashboard();
|
||||
});
|
||||
|
||||
const expandFilterConfiguration = () => {
|
||||
cy.get('.ant-collapse-header')
|
||||
.contains('Filter Configuration')
|
||||
.should('be.visible')
|
||||
.then($header => {
|
||||
cy.wrap($header)
|
||||
.closest('.ant-collapse-item')
|
||||
.invoke('hasClass', 'ant-collapse-item-active')
|
||||
.then(isExpanded => {
|
||||
if (!isExpanded) cy.wrap($header).click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('.ant-collapse-content-box').should('be.visible');
|
||||
};
|
||||
|
||||
const selectRangeTypeOption = (label: string) => {
|
||||
cy.contains('Range Type')
|
||||
.should('be.visible')
|
||||
.closest('.ant-form-item')
|
||||
.within(() => {
|
||||
cy.get('.ant-select-selector').click();
|
||||
});
|
||||
|
||||
cy.get('.ant-select-dropdown:visible')
|
||||
.contains('.ant-select-item-option', label)
|
||||
.click();
|
||||
};
|
||||
|
||||
const applyAndAssertInputs = (from: string, to: string) => {
|
||||
// Set 'from' input
|
||||
cy.get('[data-test="range-filter-from-input"]').clear();
|
||||
cy.get('[data-test="range-filter-from-input"]').type(from);
|
||||
cy.get('[data-test="range-filter-from-input"]').blur();
|
||||
|
||||
// Set 'to' input
|
||||
cy.get('[data-test="range-filter-to-input"]').clear();
|
||||
cy.get('[data-test="range-filter-to-input"]').type(to);
|
||||
cy.get('[data-test="range-filter-to-input"]').blur();
|
||||
|
||||
// Assert values without chaining after .invoke()
|
||||
cy.get('[data-test="range-filter-from-input"]')
|
||||
.invoke('val')
|
||||
.then(val => {
|
||||
expect(val).to.equal(from);
|
||||
});
|
||||
|
||||
cy.get('[data-test="range-filter-to-input"]')
|
||||
.invoke('val')
|
||||
.then(val => {
|
||||
expect(val).to.equal(to);
|
||||
});
|
||||
};
|
||||
|
||||
it('User can create a numerical range filter with "Range Inputs" display mode', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.numerical,
|
||||
testItems.filterNumericalColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
testItems.filterNumericalColumn,
|
||||
);
|
||||
|
||||
expandFilterConfiguration();
|
||||
selectRangeTypeOption('Range Inputs');
|
||||
|
||||
saveNativeFilterSettings([]);
|
||||
cy.wait(500); // allow filter to mount
|
||||
|
||||
applyAndAssertInputs('40', '70');
|
||||
});
|
||||
|
||||
it('User can change the display mode to "Slider"', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.numerical,
|
||||
testItems.filterNumericalColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
testItems.filterNumericalColumn,
|
||||
);
|
||||
|
||||
expandFilterConfiguration();
|
||||
|
||||
cy.contains('Range Type')
|
||||
.should('be.visible')
|
||||
.closest('.ant-form-item')
|
||||
.within(() => {
|
||||
cy.get('.ant-select-selector').click({ force: true });
|
||||
});
|
||||
|
||||
cy.get('.ant-select-dropdown:visible .ant-select-item-option')
|
||||
.contains(/^Slider$/)
|
||||
.click({ force: true });
|
||||
|
||||
cy.get('.ant-select-selector').should('contain.text', 'Slider');
|
||||
|
||||
saveNativeFilterSettings([]);
|
||||
|
||||
cy.get('.ant-slider', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
cy.get('[data-test="range-filter-from-input"]', {
|
||||
timeout: 5000,
|
||||
}).should('not.exist');
|
||||
cy.get('[data-test="range-filter-to-input"]', { timeout: 5000 }).should(
|
||||
'not.exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('User can change the display mode to "Slider and range input"', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
|
||||
// Re-create filter
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.numerical,
|
||||
testItems.filterNumericalColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
testItems.filterNumericalColumn,
|
||||
);
|
||||
|
||||
expandFilterConfiguration();
|
||||
selectRangeTypeOption('Slider and range input');
|
||||
|
||||
saveNativeFilterSettings([]);
|
||||
cy.wait(500);
|
||||
|
||||
applyAndAssertInputs('40', '70');
|
||||
});
|
||||
});
|
||||
|
||||
it('User can undo deleting a native filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
undoDeleteNativeFilter();
|
||||
cy.get(nativeFilters.modal.container)
|
||||
.find(nativeFilters.filtersPanel.filterName)
|
||||
.should('have.attr', 'value', testItems.topTenChart.filterColumn);
|
||||
});
|
||||
|
||||
it('User can cancel changes in native filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
cy.getBySel('filters-config-modal__name-input').type('|EDITED', {
|
||||
force: true,
|
||||
});
|
||||
cancelNativeFilterSettings();
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
cy.contains('You have removed this filter.').should('be.visible');
|
||||
});
|
||||
|
||||
it('User can create a value filter', () => {
|
||||
visitDashboard();
|
||||
enterNativeFilterEditModal(false);
|
||||
addCountryNameFilter();
|
||||
cy.get(nativeFilters.filtersPanel.filterTypeInput)
|
||||
.find(nativeFilters.filtersPanel.filterTypeItem)
|
||||
.should('have.text', testItems.filterType.value);
|
||||
saveNativeFilterSettings([]);
|
||||
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
|
||||
});
|
||||
|
||||
it('User can apply value filter with selected values', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
applyNativeFilterValueWithIndex(0, testItems.filterDefaultValue);
|
||||
cy.get(nativeFilters.applyFilter).click();
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('User can stop filtering when filter is removed', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
cy.get(nativeFilters.filterItem)
|
||||
.contains(testItems.filterDefaultValue)
|
||||
.should('be.visible');
|
||||
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
|
||||
enterNativeFilterEditModal(false);
|
||||
deleteNativeFilter();
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
nativeFilters,
|
||||
exploreView,
|
||||
dataTestChartName,
|
||||
} from 'cypress/support/directories';
|
||||
|
||||
import {
|
||||
addParentFilterWithValue,
|
||||
applyNativeFilterValueWithIndex,
|
||||
cancelNativeFilterSettings,
|
||||
checkNativeFilterTooltip,
|
||||
clickOnAddFilterInModal,
|
||||
collapseFilterOnLeftPanel,
|
||||
enterNativeFilterEditModal,
|
||||
expandFilterOnLeftPanel,
|
||||
getNativeFilterPlaceholderWithIndex,
|
||||
inputNativeFilterDefaultValue,
|
||||
saveNativeFilterSettings,
|
||||
nativeFilterTooltips,
|
||||
validateFilterContentOnDashboard,
|
||||
valueNativeFilterOptions,
|
||||
validateFilterNameOnDashboard,
|
||||
testItems,
|
||||
} from './utils';
|
||||
import {
|
||||
prepareDashboardFilters,
|
||||
SAMPLE_CHART,
|
||||
visitDashboard,
|
||||
} from './shared_dashboard_functions';
|
||||
|
||||
function selectFilter(index: number) {
|
||||
cy.get("[data-test='filter-title-container'] [role='tab']").eq(index).click();
|
||||
}
|
||||
|
||||
function closeFilterModal() {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
|
||||
cy.getBySel('native-filter-modal-cancel-button').click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('Native filters', () => {
|
||||
describe('Nativefilters tests initial state required', () => {
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
});
|
||||
|
||||
it.skip('Verify that default value is respected after revisit', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(nativeFilters.filterItem)
|
||||
.contains(testItems.filterDefaultValue)
|
||||
.should('be.visible');
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
|
||||
// reload dashboard
|
||||
cy.reload();
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
validateFilterContentOnDashboard(testItems.filterDefaultValue);
|
||||
});
|
||||
|
||||
it('User can create parent filters using "Values are dependent on other filters"', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
[
|
||||
testItems.topTenChart.filterColumnRegion,
|
||||
testItems.topTenChart.filterColumn,
|
||||
].forEach(it => {
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterName)
|
||||
.contains(it)
|
||||
.should('be.visible');
|
||||
});
|
||||
getNativeFilterPlaceholderWithIndex(1)
|
||||
.invoke('text')
|
||||
.should('equal', '214 options', { timeout: 20000 });
|
||||
// apply first filter value and validate 2nd filter is depden on 1st filter.
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '3 options', {
|
||||
timeout: 20000,
|
||||
});
|
||||
});
|
||||
|
||||
it('user can delete dependent filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
// remove year native filter to cause it disappears from parent filter input in global sales
|
||||
cy.get(nativeFilters.modal.tabsList.removeTab)
|
||||
.should('be.visible')
|
||||
.first()
|
||||
.click();
|
||||
// make sure you are seeing global sales filter which had parent filter
|
||||
cy.get(nativeFilters.modal.tabsList.filterItemsContainer)
|
||||
.children()
|
||||
.last()
|
||||
.click();
|
||||
//
|
||||
cy.wait(1000);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters').should(
|
||||
'not.exist',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('user cannot create bi-directional dependencies between filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
{ name: 'country_code', column: 'country_code' },
|
||||
{ name: 'year', column: 'year' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
|
||||
// First, make country_name dependent on region
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
|
||||
// Second, make country_code dependent on country_name
|
||||
selectFilter(2);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumn);
|
||||
|
||||
// Now select region filter and try to add dependency
|
||||
selectFilter(0);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
|
||||
// Verify that only 'year' is available as dependency for region
|
||||
// 'country_name' and 'country_code' should not be available (would create circular dependency)
|
||||
cy.get('input[aria-label^="Limit type"]').click({ force: true });
|
||||
cy.get('[role="listbox"]').should('be.visible');
|
||||
cy.get('[role="listbox"]').should('contain', 'year');
|
||||
cy.get('[role="listbox"]').should('not.contain', 'country_name');
|
||||
cy.get('[role="listbox"]').should('not.contain', 'country_code');
|
||||
cy.get('[role="listbox"]').contains('year').click();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('Dependent filter selects first item based on parent filter selection', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
|
||||
enterNativeFilterEditModal();
|
||||
|
||||
selectFilter(0);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
// cannot use saveNativeFilterSettings because there is a bug which
|
||||
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
|
||||
// to be saved when using dependent filters so,
|
||||
// you reload the window.
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.contains('Save')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
cy.reload();
|
||||
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
|
||||
// Check that dependent filter auto-selects the first item
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterContent)
|
||||
.eq(1)
|
||||
.should('contain.text', 'Bermuda');
|
||||
});
|
||||
|
||||
it('User can create filter depend on 2 other filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
{ name: 'country_code', column: 'country_code' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(2);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(exploreView.controlPanel.addFieldValue).click();
|
||||
},
|
||||
);
|
||||
// add value to the first input
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
// add value to the second input
|
||||
addParentFilterWithValue(1, testItems.topTenChart.filterColumn);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
// filters should be displayed in the left panel
|
||||
[
|
||||
testItems.topTenChart.filterColumnRegion,
|
||||
testItems.topTenChart.filterColumn,
|
||||
testItems.topTenChart.filterColumnCountryCode,
|
||||
].forEach(it => {
|
||||
validateFilterNameOnDashboard(it);
|
||||
});
|
||||
|
||||
// initially first filter shows 39 options
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '7 options');
|
||||
// initially second filter shows 409 options
|
||||
getNativeFilterPlaceholderWithIndex(1).should('have.text', '214 options');
|
||||
// verify third filter shows 409 options
|
||||
getNativeFilterPlaceholderWithIndex(2).should('have.text', '214 options');
|
||||
|
||||
// apply first filter value
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
|
||||
// verify second filter shows 409 options available still
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '214 options');
|
||||
|
||||
// verify second filter shows 69 options available still
|
||||
getNativeFilterPlaceholderWithIndex(1).should('have.text', '3 options');
|
||||
|
||||
// apply second filter value
|
||||
applyNativeFilterValueWithIndex(1, 'United States');
|
||||
|
||||
// verify number of available options for third filter - should be decreased to only one
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '1 option');
|
||||
});
|
||||
|
||||
it('User can remove parent filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(1);
|
||||
// Select dependent option and auto use platform for genre
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.get(nativeFilters.modal.tabsList.removeTab)
|
||||
.should('be.visible')
|
||||
.first()
|
||||
.click({
|
||||
force: true,
|
||||
});
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nativefilters basic interactions', () => {
|
||||
before(() => {
|
||||
visitDashboard();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
closeFilterModal();
|
||||
});
|
||||
|
||||
it('User can expand / retract native filter sidebar on a dashboard', () => {
|
||||
expandFilterOnLeftPanel();
|
||||
cy.get(nativeFilters.filtersPanel.filterGear).click({
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu').should('be.visible');
|
||||
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).should(
|
||||
'be.visible',
|
||||
);
|
||||
cy.get(nativeFilters.filterFromDashboardView.expand).should(
|
||||
'not.be.visible',
|
||||
);
|
||||
collapseFilterOnLeftPanel();
|
||||
});
|
||||
|
||||
it('User can enter filter edit pop-up by clicking on native filter edit icon', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
});
|
||||
|
||||
it('User can delete a native filter', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
cy.contains('Restore filter').should('not.exist', { timeout: 10000 });
|
||||
});
|
||||
|
||||
it('User can cancel creating a new filter', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
cancelNativeFilterSettings();
|
||||
});
|
||||
|
||||
it('Verify setting options and tooltips for value filter', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.contains('Filter value is required').scrollIntoView();
|
||||
cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
|
||||
cy.wait(300);
|
||||
|
||||
cy.contains('Filter value is required').should('be.visible').click({
|
||||
force: true,
|
||||
});
|
||||
checkNativeFilterTooltip(0, nativeFilterTooltips.preFilter);
|
||||
checkNativeFilterTooltip(1, nativeFilterTooltips.defaultValue);
|
||||
cy.get(nativeFilters.modal.container).should('be.visible');
|
||||
valueNativeFilterOptions.forEach(el => {
|
||||
cy.contains(el);
|
||||
});
|
||||
cy.contains('Values are dependent on other filters').should('not.exist');
|
||||
cy.get(
|
||||
nativeFilters.filterConfigurationSections.checkedCheckbox,
|
||||
).contains('Can select multiple values');
|
||||
checkNativeFilterTooltip(2, nativeFilterTooltips.required);
|
||||
checkNativeFilterTooltip(3, nativeFilterTooltips.defaultToFirstItem);
|
||||
checkNativeFilterTooltip(4, nativeFilterTooltips.searchAllFilterOptions);
|
||||
checkNativeFilterTooltip(5, nativeFilterTooltips.inverseSelection);
|
||||
clickOnAddFilterInModal();
|
||||
cy.contains('Values are dependent on other filters').should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
parsePostForm,
|
||||
waitForChartLoad,
|
||||
getChartAliasBySpec,
|
||||
} from 'cypress/utils';
|
||||
import { TABBED_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { expandFilterOnLeftPanel } from './utils';
|
||||
|
||||
const TREEMAP = { name: 'Treemap', viz: 'treemap_v2' };
|
||||
const LINE_CHART = { name: 'Growth Rate', viz: 'echarts_timeseries_line' };
|
||||
const BOX_PLOT = { name: 'Box plot', viz: 'box_plot' };
|
||||
const BIG_NUMBER = { name: 'Number of Girls', viz: 'big_number_total' };
|
||||
const TABLE = { name: 'Names Sorted by Num in California', viz: 'table' };
|
||||
|
||||
function topLevelTabs() {
|
||||
cy.getBySel('dashboard-component-tabs')
|
||||
.first()
|
||||
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
|
||||
.as('top-level-tabs');
|
||||
}
|
||||
|
||||
function resetTabs() {
|
||||
topLevelTabs();
|
||||
cy.get('@top-level-tabs').first().click();
|
||||
waitForChartLoad(TREEMAP);
|
||||
waitForChartLoad(BIG_NUMBER);
|
||||
waitForChartLoad(TABLE);
|
||||
}
|
||||
|
||||
describe.skip('Dashboard tabs', () => {
|
||||
before(() => {
|
||||
cy.visit(TABBED_DASHBOARD);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTabs();
|
||||
});
|
||||
|
||||
it('should switch tabs', () => {
|
||||
topLevelTabs();
|
||||
|
||||
cy.get('@top-level-tabs').first().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.first()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
cy.get('@top-level-tabs')
|
||||
.last()
|
||||
.should('not.have.class', 'ant-tabs-tab-active');
|
||||
cy.get('[data-test-chart-name="Box plot"]').should('not.exist');
|
||||
cy.get('[data-test-chart-name="Trends"]').should('not.exist');
|
||||
|
||||
cy.get('@top-level-tabs').last().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.last()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
cy.get('@top-level-tabs')
|
||||
.first()
|
||||
.should('not.have.class', 'ant-tabs-tab-active');
|
||||
waitForChartLoad(BOX_PLOT);
|
||||
|
||||
cy.get('[data-test-chart-name="Box plot"]').should('exist');
|
||||
|
||||
resetTabs();
|
||||
|
||||
// click row level tab, see 1 more chart
|
||||
cy.getBySel('dashboard-component-tabs')
|
||||
.eq(2)
|
||||
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
|
||||
.as('row-level-tabs');
|
||||
|
||||
cy.get('@row-level-tabs').last().click();
|
||||
waitForChartLoad(LINE_CHART);
|
||||
cy.get('[data-test-chart-name="Trends"]').should('exist');
|
||||
cy.get('@row-level-tabs').first().click();
|
||||
});
|
||||
|
||||
it.skip('should send new queries when tab becomes visible', () => {
|
||||
// landing in first tab
|
||||
waitForChartLoad(TREEMAP);
|
||||
|
||||
getChartAliasBySpec(TREEMAP).then(treemapAlias => {
|
||||
// apply filter
|
||||
cy.get('.Select__control').first().should('be.visible').click();
|
||||
cy.get('.Select__control input[type=text]').first().focus();
|
||||
cy.focused().type('South');
|
||||
cy.get('.Select__option').contains('South Asia').click();
|
||||
cy.get('.filter button:not(:disabled)').contains('Apply').click();
|
||||
|
||||
// send new query from same tab
|
||||
cy.wait(treemapAlias).then(({ request }) => {
|
||||
const requestBody = parsePostForm(request.body);
|
||||
const requestParams = JSON.parse(requestBody.form_data as string);
|
||||
expect(requestParams.extra_filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cy.intercept('**/superset/explore_json/?*').as('legacyChartData');
|
||||
// click row level tab, send 1 more query
|
||||
cy.get('.ant-tabs-tab').contains('row tab 2').click();
|
||||
|
||||
cy.wait('@legacyChartData').then(({ request }) => {
|
||||
const requestBody = parsePostForm(request.body);
|
||||
const requestParams = JSON.parse(requestBody.form_data as string);
|
||||
expect(requestParams.extra_filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
expect(requestParams.viz_type).eq(LINE_CHART.viz);
|
||||
});
|
||||
|
||||
cy.intercept('POST', '**/api/v1/chart/data?*').as('v1ChartData');
|
||||
|
||||
// click top level tab, send 1 more query
|
||||
cy.get('.ant-tabs-tab').contains('Tab B').click();
|
||||
|
||||
cy.wait('@v1ChartData').then(({ request }) => {
|
||||
expect(request.body.queries[0].filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
});
|
||||
|
||||
getChartAliasBySpec(BOX_PLOT).then(boxPlotAlias => {
|
||||
// navigate to filter and clear filter
|
||||
cy.get('.ant-tabs-tab').contains('Tab A').click();
|
||||
cy.get('.ant-tabs-tab').contains('row tab 1').click();
|
||||
|
||||
cy.get('.Select__clear-indicator').click();
|
||||
cy.get('.filter button:not(:disabled)').contains('Apply').click();
|
||||
|
||||
// trigger 1 new query
|
||||
waitForChartLoad(TREEMAP);
|
||||
// make sure query API not requested multiple times
|
||||
cy.on('fail', err => {
|
||||
expect(err.message).to.include('timed out waiting');
|
||||
return false;
|
||||
});
|
||||
|
||||
cy.wait(boxPlotAlias, { timeout: 1000 }).then(() => {
|
||||
throw new Error('Unexpected API call.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update size when switch tab', () => {
|
||||
cy.get('@top-level-tabs').last().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.last()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
|
||||
expandFilterOnLeftPanel();
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('@top-level-tabs').first().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.first()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("[data-test-viz-type='treemap_v2'] .chart-container").then(
|
||||
$chartContainer => {
|
||||
expect($chartContainer.get(0).scrollWidth).eq(
|
||||
$chartContainer.get(0).offsetWidth,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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 { parsePostForm, JsonObject, waitForChartLoad } from 'cypress/utils';
|
||||
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { WORLD_HEALTH_CHARTS } from './utils';
|
||||
|
||||
describe('Dashboard form data', () => {
|
||||
const urlParams = { param1: '123', param2: 'abc' };
|
||||
before(() => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD, { qs: urlParams });
|
||||
});
|
||||
|
||||
it('should apply url params to slice requests', () => {
|
||||
cy.intercept('**/api/v1/chart/data?*', request => {
|
||||
// TODO: export url params to chart data API
|
||||
request.body.queries.forEach((query: { url_params: JsonObject }) => {
|
||||
expect(query.url_params).deep.eq(urlParams);
|
||||
});
|
||||
});
|
||||
cy.intercept('**/superset/explore_json/*', request => {
|
||||
const requestParams = JSON.parse(
|
||||
parsePostForm(request.body).form_data as string,
|
||||
);
|
||||
expect(requestParams.url_params).deep.eq(urlParams);
|
||||
});
|
||||
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
describe('AdhocFilters', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('GET', '**/api/v1/datasource/table/*/column/name/values').as(
|
||||
'filterValues',
|
||||
);
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('postJson');
|
||||
cy.intercept('GET', '**/superset/explore_json/**').as('getJson');
|
||||
cy.visitChartByName('Boys'); // a table chart
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
});
|
||||
|
||||
let numScripts = 0;
|
||||
|
||||
it('Should load AceEditor scripts when needed', () => {
|
||||
cy.get('script').then(nodes => {
|
||||
numScripts = nodes.length;
|
||||
});
|
||||
|
||||
cy.get('[data-test=adhoc_filters]').within(() => {
|
||||
cy.get('.Select__control').scrollIntoView();
|
||||
cy.get('.Select__control').click();
|
||||
cy.get('input[type=text]').focus();
|
||||
cy.focused().type('name{enter}');
|
||||
cy.get("div[role='button']").first().click();
|
||||
});
|
||||
|
||||
// antd tabs do lazy loading, so we need to click on tab with ace editor
|
||||
cy.get('#filter-edit-popover').within(() => {
|
||||
cy.get('.ant-tabs-tab').contains('Custom SQL').click();
|
||||
cy.get('.ant-tabs-tab').contains('Simple').click();
|
||||
});
|
||||
|
||||
cy.get('script').then(nodes => {
|
||||
// should load new script chunks for SQL editor
|
||||
expect(nodes.length).to.greaterThan(numScripts);
|
||||
});
|
||||
});
|
||||
|
||||
it('Set simple adhoc filter', () => {
|
||||
cy.get('[aria-label="Comparator option"] .Select__control').click();
|
||||
cy.get('[data-test=adhoc-filter-simple-value] input[type=text]').focus();
|
||||
cy.focused().type('Jack{enter}', { delay: 20 });
|
||||
|
||||
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
|
||||
|
||||
cy.get(
|
||||
'[data-test=adhoc_filters] .Select__control span.option-label',
|
||||
).contains('name = Jack');
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@postJson',
|
||||
chartSelector: 'svg',
|
||||
});
|
||||
});
|
||||
|
||||
it('Set custom adhoc filter', () => {
|
||||
const filterType = 'name';
|
||||
const filterContent = "'Amy' OR name = 'Donald'";
|
||||
|
||||
cy.get('[data-test=adhoc_filters] .Select__control').scrollIntoView();
|
||||
cy.get('[data-test=adhoc_filters] .Select__control').click();
|
||||
|
||||
// remove previous input
|
||||
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
|
||||
cy.focused().type('{backspace}');
|
||||
|
||||
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
|
||||
cy.focused().type(`${filterType}{enter}`);
|
||||
|
||||
cy.wait('@filterValues');
|
||||
|
||||
// selecting a new filter should auto-open the popup,
|
||||
// so the tab should be visible by now
|
||||
cy.get('#filter-edit-popover #adhoc-filter-edit-tabs-tab-SQL').click();
|
||||
cy.get('#filter-edit-popover .ace_content').click();
|
||||
cy.get('#filter-edit-popover .ace_text-input').type(filterContent);
|
||||
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
|
||||
|
||||
// check if the filter was saved correctly
|
||||
cy.get(
|
||||
'[data-test=adhoc_filters] .Select__control span.option-label',
|
||||
).contains(`${filterType} = ${filterContent}`);
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@postJson',
|
||||
chartSelector: 'svg',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* 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 { interceptChart } from 'cypress/utils';
|
||||
|
||||
describe('AdhocMetrics', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
cy.visitChartByName('Num Births Trend');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
});
|
||||
|
||||
it('Clear metric and set simple adhoc metric', () => {
|
||||
const metric = 'sum(num_girls)';
|
||||
const metricName = 'Sum Girls';
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="remove-control-button"]')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.contains('Drop columns/metrics here or click')
|
||||
.click();
|
||||
|
||||
// Title edit for saved metrics is disabled - switch to Simple
|
||||
cy.get('[id="adhoc-metric-edit-tabs-tab-SIMPLE"]').click();
|
||||
|
||||
cy.get('[data-test="AdhocMetricEditTitle#trigger"]').click();
|
||||
cy.get('[data-test="AdhocMetricEditTitle#input"]').type(metricName);
|
||||
|
||||
cy.get('input[aria-label="Select column"]').click();
|
||||
cy.get('input[aria-label="Select column"]').type('num_girls{enter}');
|
||||
cy.get('input[aria-label="Select aggregate options"]').click();
|
||||
cy.get('input[aria-label="Select aggregate options"]').type('sum{enter}');
|
||||
|
||||
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
|
||||
|
||||
cy.get('[data-test="control-label"]').contains(metricName);
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: `${metric} AS "${metricName}"`, // SQL statement
|
||||
});
|
||||
});
|
||||
|
||||
xit('Switch from simple to custom sql', () => {
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="metric-option"]')
|
||||
.should('have.length', 1);
|
||||
|
||||
// select column "num"
|
||||
cy.get('[data-test=metrics]').find('.Select__clear-indicator').click();
|
||||
|
||||
cy.get('[data-test=metrics]').find('.Select__control').click();
|
||||
|
||||
cy.get('[data-test=metrics]').find('.Select__control input').type('num');
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('.option-label')
|
||||
.first()
|
||||
.should('have.text', 'num')
|
||||
.click();
|
||||
|
||||
// add custom SQL
|
||||
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
|
||||
cy.get('[data-test=metrics-edit-popover]').within(() => {
|
||||
cy.get('.ace_content').click();
|
||||
cy.get('.ace_text-input').type('/COUNT(DISTINCT name)', { force: true });
|
||||
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
|
||||
});
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
|
||||
const metric = 'SUM(num)/COUNT(DISTINCT name)';
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: `${metric} AS "${metric}"`,
|
||||
});
|
||||
});
|
||||
|
||||
xit('Switch from custom sql tabs to simple', () => {
|
||||
cy.get('[data-test=metrics]').within(() => {
|
||||
cy.get('.Select__dropdown-indicator').click();
|
||||
cy.get('input[type=text]').type('num_girls{enter}');
|
||||
});
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="metric-option"]')
|
||||
.should('have.length', 2);
|
||||
|
||||
cy.get('#metrics-edit-popover').within(() => {
|
||||
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
|
||||
cy.get('.ace_identifier').contains('num_girls');
|
||||
cy.get('.ace_content').click();
|
||||
cy.get('.ace_text-input').type('{selectall}{backspace}SUM(num)');
|
||||
cy.get('#adhoc-metric-edit-tabs-tab-SIMPLE').click();
|
||||
cy.get('.Select__single-value').contains(/^num$/);
|
||||
cy.get('button').contains('Save').click();
|
||||
});
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
|
||||
const metric = 'SUM(num)';
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: `${metric} AS "${metric}"`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 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 { interceptV1ChartData } from './utils';
|
||||
|
||||
describe('Advanced analytics', () => {
|
||||
beforeEach(() => {
|
||||
interceptV1ChartData();
|
||||
cy.intercept('PUT', '**/api/v1/explore/**').as('putExplore');
|
||||
cy.intercept('GET', '**/explore/**').as('getExplore');
|
||||
});
|
||||
|
||||
it('Create custom time compare', () => {
|
||||
cy.visitChartByName('Num Births Trend');
|
||||
cy.verifySliceSuccess({ waitAlias: '@v1Data' });
|
||||
|
||||
cy.get('.ant-collapse-header')
|
||||
.contains('Advanced analytics')
|
||||
.click({ force: true });
|
||||
|
||||
cy.get('[data-test=time_compare]').find('.ant-select').click();
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('input[type=search]')
|
||||
.type('28 days{enter}');
|
||||
|
||||
cy.get('[data-test=time_compare]').find('input[type=search]').clear();
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('input[type=search]')
|
||||
.type('1 year{enter}');
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.wait('@v1Data');
|
||||
cy.wait('@putExplore');
|
||||
|
||||
cy.reload();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@v1Data',
|
||||
});
|
||||
cy.wait('@getExplore');
|
||||
cy.get('.ant-collapse-header')
|
||||
.contains('Advanced analytics')
|
||||
.click({ force: true });
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('.ant-select-selector')
|
||||
.contains('28 days');
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('.ant-select-selector')
|
||||
.contains('1 year');
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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 { interceptChart } from 'cypress/utils';
|
||||
|
||||
describe('Annotations', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
it('Create formula annotation y-axis goal line', () => {
|
||||
cy.visitChartByName('Num Births Trend');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
const layerLabel = 'Goal line';
|
||||
|
||||
// get by text Annotations and Layers
|
||||
cy.get('span').contains('Annotations and Layers').click();
|
||||
|
||||
cy.get('[data-test=annotation_layers]').click();
|
||||
|
||||
cy.get('[data-test="popover-content"]').within(() => {
|
||||
cy.get('[aria-label=Name]').type(layerLabel);
|
||||
cy.get('[aria-label=Formula]').type('y=1400000');
|
||||
cy.get('button').contains('OK').click();
|
||||
});
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.get('[data-test=annotation_layers]').contains(layerLabel);
|
||||
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// ***********************************************
|
||||
// Tests for links in the explore UI
|
||||
// ***********************************************
|
||||
|
||||
import rison from 'rison';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { interceptChart } from 'cypress/utils';
|
||||
import { HEALTH_POP_FORM_DATA_DEFAULTS } from './visualizations/shared.helper';
|
||||
|
||||
const apiURL = (endpoint: string, queryObject: Record<string, unknown>) =>
|
||||
`${endpoint}?q=${rison.encode(queryObject)}`;
|
||||
|
||||
describe('Test explore links', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
it('Open and close view query modal', () => {
|
||||
cy.visitChartByName('Growth Rate');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[aria-label="Menu actions trigger"]').click();
|
||||
cy.get('span').contains('View query').parent().click();
|
||||
cy.wait('@chartData').then(() => {
|
||||
cy.get('code');
|
||||
});
|
||||
cy.get('.ant-modal-content').within(() => {
|
||||
cy.get('button.ant-modal-close').first().click({ force: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('Test iframe link', () => {
|
||||
cy.visitChartByName('Growth Rate');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[aria-label="Menu actions trigger"]').click();
|
||||
cy.get('div[role="menuitem"]').within(() => {
|
||||
cy.contains('Share').parent().click();
|
||||
});
|
||||
cy.getBySel('embed-code-button').click();
|
||||
cy.get('#embed-code-popover').within(() => {
|
||||
cy.get('textarea[name=embedCode]').contains('iframe');
|
||||
});
|
||||
});
|
||||
|
||||
it('Test chart save as AND overwrite', () => {
|
||||
interceptChart({ legacy: false }).as('tableChartData');
|
||||
|
||||
const formData = {
|
||||
...HEALTH_POP_FORM_DATA_DEFAULTS,
|
||||
viz_type: 'table',
|
||||
metrics: ['sum__SP_POP_TOTL'],
|
||||
groupby: ['country_name'],
|
||||
};
|
||||
const newChartName = `Test chart [${nanoid()}]`;
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
|
||||
cy.url().then(() => {
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="saveas-radio"]').check();
|
||||
cy.get('[data-test="new-chart-name"]').type(newChartName, {
|
||||
force: true,
|
||||
});
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
|
||||
cy.visitChartByName(newChartName);
|
||||
|
||||
// Overwriting!
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="save-overwrite-radio"]').check();
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
|
||||
const query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'slice_name',
|
||||
opr: 'eq',
|
||||
value: newChartName,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
cy.deleteChartByName(newChartName, true);
|
||||
});
|
||||
});
|
||||
|
||||
it('Test chart save as and add to new dashboard', () => {
|
||||
const chartName = 'Growth Rate';
|
||||
const newChartName = `${chartName} [${nanoid()}]`;
|
||||
const dashboardTitle = `Test dashboard [${nanoid()}]`;
|
||||
|
||||
cy.visitChartByName(chartName);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="saveas-radio"]').check();
|
||||
cy.get('[data-test="new-chart-name"]').click();
|
||||
cy.get('[data-test="new-chart-name"]').clear();
|
||||
cy.get('[data-test="new-chart-name"]').type(newChartName);
|
||||
// Add a new option using the "CreatableSelect" feature
|
||||
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
|
||||
.find('input[aria-label="Select a dashboard"]')
|
||||
.type(`${dashboardTitle}`, { force: true });
|
||||
|
||||
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
let query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'dashboard_title',
|
||||
opr: 'eq',
|
||||
value: dashboardTitle,
|
||||
},
|
||||
],
|
||||
};
|
||||
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
|
||||
cy.visitChartByName(newChartName);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="save-overwrite-radio"]').check();
|
||||
cy.get('[data-test="new-chart-name"]').click();
|
||||
cy.get('[data-test="new-chart-name"]').clear();
|
||||
cy.get('[data-test="new-chart-name"]').type(newChartName);
|
||||
// This time around, typing the same dashboard name
|
||||
// will select the existing one
|
||||
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
|
||||
.find('input[aria-label^="Select a dashboard"]')
|
||||
.type(`${dashboardTitle}{enter}`, { force: true });
|
||||
|
||||
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'slice_name',
|
||||
opr: 'eq',
|
||||
value: chartName,
|
||||
},
|
||||
],
|
||||
};
|
||||
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'dashboard_title',
|
||||
opr: 'eq',
|
||||
value: dashboardTitle,
|
||||
},
|
||||
],
|
||||
};
|
||||
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
cy.deleteDashboardByName(dashboardTitle, true);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 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 { interceptChart } from 'cypress/utils';
|
||||
|
||||
describe('Visualization > Big Number with Trendline', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
const BIG_NUMBER_FORM_DATA = {
|
||||
datasource: '2__table',
|
||||
viz_type: 'big_number',
|
||||
slice_id: 42,
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '2000 : 2014-01-02',
|
||||
metric: 'sum__SP_POP_TOTL',
|
||||
adhoc_filters: [],
|
||||
compare_lag: '10',
|
||||
compare_suffix: 'over 10Y',
|
||||
y_axis_format: '.3s',
|
||||
show_trend_line: true,
|
||||
start_y_axis_at_zero: true,
|
||||
color_picker: {
|
||||
r: 0,
|
||||
g: 122,
|
||||
b: 135,
|
||||
a: 1,
|
||||
},
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
chartSelector: '.superset-legacy-chart-big-number',
|
||||
});
|
||||
}
|
||||
|
||||
it('should work', () => {
|
||||
verify(BIG_NUMBER_FORM_DATA);
|
||||
cy.get('.chart-container .header-line');
|
||||
cy.get('.chart-container canvas');
|
||||
});
|
||||
|
||||
it('should work without subheader', () => {
|
||||
verify({
|
||||
...BIG_NUMBER_FORM_DATA,
|
||||
compare_lag: null,
|
||||
});
|
||||
cy.get('.chart-container .header-line');
|
||||
cy.get('.chart-container .subtitle-line').should('not.exist');
|
||||
cy.get('.chart-container canvas');
|
||||
});
|
||||
|
||||
it('should not render trendline when hidden', () => {
|
||||
verify({
|
||||
...BIG_NUMBER_FORM_DATA,
|
||||
show_trend_line: false,
|
||||
});
|
||||
cy.get('[data-test="chart-container"] .header-line');
|
||||
cy.get('[data-test="chart-container"] canvas').should('not.exist');
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* 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 { interceptChart } from 'cypress/utils';
|
||||
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
|
||||
|
||||
describe('Visualization > Big Number Total', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
const BIG_NUMBER_DEFAULTS = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
viz_type: 'big_number_total',
|
||||
};
|
||||
|
||||
it('Test big number chart with adhoc metric', () => {
|
||||
const formData = { ...BIG_NUMBER_DEFAULTS, metric: NUM_METRIC };
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test big number chart with simple filter', () => {
|
||||
const filters = [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'name',
|
||||
operator: 'IN',
|
||||
comparator: ['Aaron', 'Amy', 'Andrea'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_4y6teao56zs_ebjsvwy48c',
|
||||
},
|
||||
];
|
||||
|
||||
const formData = {
|
||||
...BIG_NUMBER_DEFAULTS,
|
||||
metric: 'count',
|
||||
adhoc_filters: filters,
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
});
|
||||
|
||||
it('Test big number chart ignores groupby', () => {
|
||||
const formData = {
|
||||
...BIG_NUMBER_DEFAULTS,
|
||||
metric: NUM_METRIC,
|
||||
groupby: ['state'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.wait(['@chartData']).then(async ({ response }) => {
|
||||
cy.verifySliceContainer();
|
||||
const responseBody = response?.body;
|
||||
expect(responseBody.result[0].query).not.contains(formData.groupby[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 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 { getDatasetId } from './shared.helper';
|
||||
|
||||
describe('Visualization > Box Plot', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const getBoxPlotFormData = datasetId => ({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'box_plot',
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '1960-01-01 : now',
|
||||
metrics: ['sum__SP_POP_TOTL'],
|
||||
adhoc_filters: [],
|
||||
groupby: ['region'],
|
||||
limit: '25',
|
||||
color_scheme: 'bnbColors',
|
||||
whisker_options: 'Min/max (no outliers)',
|
||||
});
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
verify(getBoxPlotFormData(datasetId));
|
||||
cy.get('.chart-container .box_plot canvas').should('have.length', 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
verify(getBoxPlotFormData(datasetId));
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* 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 { getDatasetId } from './shared.helper';
|
||||
|
||||
describe('Visualization > Bubble', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const getBubbleFormData = datasetId => ({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'bubble',
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '2011-01-01 : 2011-01-02',
|
||||
series: 'region',
|
||||
entity: 'country_name',
|
||||
x: 'sum__SP_RUR_TOTL_ZS',
|
||||
y: 'sum__SP_DYN_LE00_IN',
|
||||
size: 'sum__SP_POP_TOTL',
|
||||
max_bubble_size: '50',
|
||||
limit: 0,
|
||||
color_scheme: 'bnbColors',
|
||||
show_legend: true,
|
||||
x_axis_label: '',
|
||||
left_margin: 'auto',
|
||||
x_axis_format: '.3s',
|
||||
x_ticks_layout: 'auto',
|
||||
x_log_scale: false,
|
||||
x_axis_showminmax: false,
|
||||
y_axis_label: '',
|
||||
bottom_margin: 'auto',
|
||||
y_axis_format: '.3s',
|
||||
y_log_scale: false,
|
||||
y_axis_showminmax: false,
|
||||
});
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
|
||||
}
|
||||
|
||||
it('should work with filter', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
verify({
|
||||
...getBubbleFormData(datasetId),
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: '==',
|
||||
comparator: 'South Asia',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_b2tfg1rs8y_8kmrcyxvsqd',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('[data-test="chart-container"]').should('be.visible');
|
||||
cy.get('[data-test="chart-container"]').within(() => {
|
||||
cy.get('svg').find('.nv-point-clips circle').should('have.length', 8);
|
||||
});
|
||||
cy.get('[data-test="chart-container"]').then(nodeList => {
|
||||
// Check that all circles have same color.
|
||||
const color = nodeList[0].getAttribute('fill');
|
||||
const circles = Array.prototype.slice.call(nodeList);
|
||||
expect(circles.every(c => c.getAttribute('fill') === color)).to.equal(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes and apply the scheme', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
cy.visitChartByParams(getBubbleFormData(datasetId));
|
||||
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
cy.get('[data-test=run-query-button]').click();
|
||||
cy.get('.bubble .nv-legend .nv-legend-symbol').should(
|
||||
'have.css',
|
||||
'fill',
|
||||
'rgb(31, 168, 201)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
describe('Visualization > Compare', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const COMPARE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'compare',
|
||||
slice_id: 60,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metrics: ['count'],
|
||||
adhoc_filters: [],
|
||||
groupby: [],
|
||||
order_desc: true,
|
||||
contribution: false,
|
||||
row_limit: 50000,
|
||||
color_scheme: 'bnbColors',
|
||||
x_axis_label: 'Frequency',
|
||||
bottom_margin: 'auto',
|
||||
x_ticks_layout: 'auto',
|
||||
x_axis_format: 'smart_date',
|
||||
x_axis_showminmax: false,
|
||||
y_axis_label: 'Num',
|
||||
left_margin: 'auto',
|
||||
y_axis_showminmax: false,
|
||||
y_log_scale: false,
|
||||
y_axis_format: '.3s',
|
||||
rolling_type: 'None',
|
||||
comparison_type: 'values',
|
||||
annotation_layers: [],
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
|
||||
}
|
||||
|
||||
it('should work without groupby', () => {
|
||||
verify(COMPARE_FORM_DATA);
|
||||
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should with group by', () => {
|
||||
verify({
|
||||
...COMPARE_FORM_DATA,
|
||||
groupby: ['gender'],
|
||||
});
|
||||
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 2);
|
||||
});
|
||||
|
||||
it('should work with filter', () => {
|
||||
verify({
|
||||
...COMPARE_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'gender',
|
||||
operator: '==',
|
||||
comparator: 'boy',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes and apply the scheme', () => {
|
||||
verify(COMPARE_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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 { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
|
||||
|
||||
describe('Download Chart > Bar chart', () => {
|
||||
const VIZ_DEFAULTS = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
it('download chart with image works', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: NUM_METRIC,
|
||||
groupby: ['state'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.get('.header-with-actions .ant-dropdown-trigger').click();
|
||||
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click();
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(1) > .ant-dropdown-menu-submenu-title',
|
||||
).click();
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)',
|
||||
).click();
|
||||
|
||||
cy.verifyDownload('.jpg', {
|
||||
contains: true,
|
||||
timeout: 25000,
|
||||
interval: 600,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
describe('Visualization > Gauge', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const GAUGE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'gauge_chart',
|
||||
metric: 'count',
|
||||
adhoc_filters: [],
|
||||
slice_id: 54,
|
||||
row_limit: 10,
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work', () => {
|
||||
verify(GAUGE_FORM_DATA);
|
||||
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...GAUGE_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'country_code',
|
||||
operator: '==',
|
||||
comparator: 'USA',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
isExtra: false,
|
||||
isNew: false,
|
||||
filterOptionName: 'filter_jaemvkxd5h_ku22m3wyo',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(GAUGE_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('bnbColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
type adhocFilter = {
|
||||
expressionType: string;
|
||||
subject: string;
|
||||
operator: string;
|
||||
comparator: string;
|
||||
clause: string;
|
||||
sqlExpression: string | null;
|
||||
filterOptionName: string;
|
||||
};
|
||||
|
||||
describe('Visualization > Graph', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const GRAPH_FORM_DATA = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'graph_chart',
|
||||
slice_id: 55,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metric: 'sum__value',
|
||||
adhoc_filters: [],
|
||||
source: 'source',
|
||||
target: 'target',
|
||||
row_limit: 50000,
|
||||
show_legend: true,
|
||||
color_scheme: 'bnbColors',
|
||||
};
|
||||
|
||||
function verify(formData: {
|
||||
[name: string]: string | boolean | number | Array<adhocFilter>;
|
||||
}): void {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work with ad-hoc metric', () => {
|
||||
verify(GRAPH_FORM_DATA);
|
||||
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...GRAPH_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'source',
|
||||
operator: '==',
|
||||
comparator: 'Agriculture',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(GRAPH_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('bnbColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
describe('Visualization > Pie', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const PIE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'pie',
|
||||
slice_id: 55,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metric: 'sum__num',
|
||||
adhoc_filters: [],
|
||||
groupby: ['gender'],
|
||||
row_limit: 50000,
|
||||
pie_label_type: 'key',
|
||||
donut: false,
|
||||
show_legend: true,
|
||||
show_labels: true,
|
||||
labels_outside: true,
|
||||
color_scheme: 'bnbColors',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work with ad-hoc metric', () => {
|
||||
verify(PIE_FORM_DATA);
|
||||
cy.get('.chart-container .pie canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...PIE_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'gender',
|
||||
operator: '==',
|
||||
comparator: 'boy',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .pie canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(PIE_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
describe('Visualization > Pivot Table', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
|
||||
});
|
||||
|
||||
const PIVOT_TABLE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'pivot_table_v2',
|
||||
slice_id: 61,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metrics: ['sum__num'],
|
||||
adhoc_filters: [],
|
||||
groupbyRows: ['name'],
|
||||
groupbyColumns: ['state'],
|
||||
series_limit: 5000,
|
||||
aggregateFunction: 'Sum',
|
||||
rowTotals: true,
|
||||
colTotals: true,
|
||||
valueFormat: '.3s',
|
||||
combineMetric: false,
|
||||
};
|
||||
|
||||
const TEST_METRIC = {
|
||||
expressionType: 'SIMPLE',
|
||||
column: {
|
||||
id: 338,
|
||||
column_name: 'num_boys',
|
||||
expression: '',
|
||||
filterable: false,
|
||||
groupby: false,
|
||||
is_dttm: false,
|
||||
type: 'BIGINT',
|
||||
optionName: '_col_num_boys',
|
||||
},
|
||||
aggregate: 'SUM',
|
||||
hasCustomLabel: false,
|
||||
label: 'SUM(num_boys)',
|
||||
optionName: 'metric_gvpdjt0v2qf_6hkf56o012',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
}
|
||||
|
||||
it('should work with single groupby', () => {
|
||||
verify(PIVOT_TABLE_FORM_DATA);
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
});
|
||||
|
||||
it('should work with more than one groupby', () => {
|
||||
verify({
|
||||
...PIVOT_TABLE_FORM_DATA,
|
||||
groupbyRows: ['name', 'gender'],
|
||||
});
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
|
||||
});
|
||||
|
||||
it('should work with multiple metrics', () => {
|
||||
verify({
|
||||
...PIVOT_TABLE_FORM_DATA,
|
||||
metrics: ['sum__num', TEST_METRIC],
|
||||
});
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
|
||||
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
});
|
||||
|
||||
it('should work with multiple groupby and multiple metrics', () => {
|
||||
verify({
|
||||
...PIVOT_TABLE_FORM_DATA,
|
||||
groupbyRows: ['name', 'gender'],
|
||||
metrics: ['sum__num', TEST_METRIC],
|
||||
});
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
describe('Visualization > Sunburst', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
|
||||
});
|
||||
|
||||
const SUNBURST_FORM_DATA = {
|
||||
datasource: '2__table',
|
||||
viz_type: 'sunburst_v2',
|
||||
slice_id: 47,
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: 'No filter',
|
||||
columns: ['region'],
|
||||
metric: 'sum__SP_POP_TOTL',
|
||||
adhoc_filters: [],
|
||||
row_limit: 50000,
|
||||
color_scheme: 'bnbColors',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
}
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work without secondary metric', () => {
|
||||
verify(SUNBURST_FORM_DATA);
|
||||
cy.get('.chart-container svg g path').should('have.length', 7);
|
||||
});
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work with secondary metric', () => {
|
||||
verify({
|
||||
...SUNBURST_FORM_DATA,
|
||||
secondary_metric: 'sum__SP_RUR_TOTL',
|
||||
});
|
||||
cy.get('.chart-container svg g path').should('have.length', 7);
|
||||
});
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work with multiple columns', () => {
|
||||
verify({
|
||||
...SUNBURST_FORM_DATA,
|
||||
columns: ['region', 'country_name'],
|
||||
});
|
||||
cy.get('.chart-container svg g path').should('have.length', 221);
|
||||
});
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work with filter', () => {
|
||||
verify({
|
||||
...SUNBURST_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: 'IN',
|
||||
comparator: ['South Asia', 'North America'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_txje2ikiv6_wxmn0qwd1xo',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container svg g path').should('have.length', 2);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(SUNBURST_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,474 +0,0 @@
|
||||
/**
|
||||
* 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 { interceptChart } from 'cypress/utils';
|
||||
import {
|
||||
FORM_DATA_DEFAULTS,
|
||||
NUM_METRIC,
|
||||
MAX_DS,
|
||||
MAX_STATE,
|
||||
SIMPLE_FILTER,
|
||||
} from './shared.helper';
|
||||
|
||||
// Table
|
||||
describe('Visualization > Table', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
const VIZ_DEFAULTS = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
viz_type: 'table',
|
||||
row_limit: 1000,
|
||||
};
|
||||
|
||||
const PERCENT_METRIC = {
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'CAST(SUM(num_girls) AS FLOAT)/SUM(num)',
|
||||
column: null,
|
||||
aggregate: null,
|
||||
hasCustomLabel: true,
|
||||
label: 'Girls',
|
||||
optionName: 'metric_6qwzgc8bh2v_zox7hil1mzs',
|
||||
};
|
||||
|
||||
it('Use default time column', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
granularity_sqla: undefined,
|
||||
metrics: ['count'],
|
||||
});
|
||||
cy.get('[data-test=adhoc_filters]').contains('ds');
|
||||
});
|
||||
|
||||
it('Format non-numeric metrics correctly', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P3M',
|
||||
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
|
||||
});
|
||||
// when format with smart_date, time column use format by granularity
|
||||
cy.get('.chart-container td:nth-child(1)').contains('2008 Q1');
|
||||
// other column with timestamp use adaptive formatting
|
||||
cy.get('.chart-container td:nth-child(3)').contains('2008');
|
||||
cy.get('.chart-container td:nth-child(4)').contains('TX');
|
||||
});
|
||||
|
||||
it('Format with table_timestamp_format', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P3M',
|
||||
table_timestamp_format: '%Y-%m-%d %H:%M',
|
||||
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
|
||||
});
|
||||
// time column and MAX(ds) metric column both use UTC time
|
||||
cy.get('.chart-container td:nth-child(1)').contains('2008-01-01 00:00');
|
||||
cy.get('.chart-container td:nth-child(3)').contains('2008-01-01 00:00');
|
||||
cy.get('.chart-container td')
|
||||
.contains('2008-01-01 08:00')
|
||||
.should('not.exist');
|
||||
// time column should not use time granularity when timestamp format is set
|
||||
cy.get('.chart-container td').contains('2008 Q1').should('not.exist');
|
||||
// other num numeric metric column should stay as string
|
||||
cy.get('.chart-container td').contains('TX');
|
||||
});
|
||||
|
||||
it('Test table with groupby', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC, MAX_DS],
|
||||
groupby: ['name'],
|
||||
});
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: /GROUP BY.*name/i,
|
||||
chartSelector: 'table',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test table with groupby + time column', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P3M',
|
||||
metrics: [NUM_METRIC, MAX_DS],
|
||||
groupby: ['name'],
|
||||
});
|
||||
cy.wait('@chartData').then(({ response }) => {
|
||||
cy.verifySliceContainer('table');
|
||||
const records = response?.body.result[0].data;
|
||||
// should sort by first metric when no sort by metric is set
|
||||
expect(records[0][NUM_METRIC.label]).greaterThan(
|
||||
records[1][NUM_METRIC.label],
|
||||
);
|
||||
});
|
||||
|
||||
// should handle frontend sorting correctly
|
||||
cy.get('.chart-container th').contains('name').click();
|
||||
cy.get('.chart-container td:nth-child(2):eq(0)').contains('Adam');
|
||||
cy.get('.chart-container th').contains('ds').click();
|
||||
cy.get('.chart-container th').contains('ds').click();
|
||||
cy.get('.chart-container td:nth-child(1):eq(0)').contains('2008');
|
||||
});
|
||||
|
||||
it('Test table with percent metrics and groupby', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
percent_metrics: PERCENT_METRIC,
|
||||
metrics: [],
|
||||
groupby: ['name'],
|
||||
});
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby order desc', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: NUM_METRIC,
|
||||
groupby: ['name'],
|
||||
order_desc: true,
|
||||
});
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby + order by + no metric', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [],
|
||||
groupby: ['name'],
|
||||
timeseries_limit_metric: NUM_METRIC,
|
||||
order_desc: true,
|
||||
});
|
||||
// should contain only the group by column
|
||||
cy.get('.chart-container th').its('length').should('eq', 1);
|
||||
// should order correctly
|
||||
cy.get('.chart-container td:eq(0)').contains('Michael');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby and limit', () => {
|
||||
const limit = 10;
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: NUM_METRIC,
|
||||
groupby: ['name'],
|
||||
row_limit: limit,
|
||||
};
|
||||
cy.visitChartByParams(formData);
|
||||
cy.wait('@chartData').then(({ response }) => {
|
||||
cy.verifySliceContainer('table');
|
||||
expect(response?.body.result[0].data.length).to.eq(limit);
|
||||
});
|
||||
cy.get('[data-test="row-count-label"]').contains('10 rows');
|
||||
});
|
||||
|
||||
it('Test table with columns and row limit', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
// should still work when query_mode is not-set/invalid
|
||||
query_mode: undefined,
|
||||
all_columns: ['state'],
|
||||
metrics: [],
|
||||
row_limit: 100,
|
||||
});
|
||||
|
||||
// should display in raw records mode
|
||||
cy.get(
|
||||
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
|
||||
).contains('Raw records');
|
||||
cy.get('div[data-test="all_columns"]').should('be.visible');
|
||||
cy.get('div[data-test="groupby"]').should('not.exist');
|
||||
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
cy.get('[data-test="row-count-label"]').contains('100 rows');
|
||||
|
||||
// should allow switch back to aggregate mode
|
||||
cy.get('div[data-test="query_mode"] .ant-radio-button-wrapper')
|
||||
.contains('Aggregate')
|
||||
.click();
|
||||
cy.get(
|
||||
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
|
||||
).contains('Aggregate');
|
||||
cy.get('div[data-test="all_columns"]').should('not.exist');
|
||||
cy.get('div[data-test="groupby"]').should('be.visible');
|
||||
});
|
||||
|
||||
it('Test table with columns, ordering, and row limit', () => {
|
||||
const limit = 10;
|
||||
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
query_mode: 'raw',
|
||||
all_columns: ['name', 'state', 'ds', 'num'],
|
||||
metrics: [],
|
||||
row_limit: limit,
|
||||
order_by_cols: ['["num", false]'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.wait('@chartData').then(({ response }) => {
|
||||
cy.verifySliceContainer('table');
|
||||
const records = response?.body.result[0].data;
|
||||
expect(records[0].num).greaterThan(records[records.length - 1].num);
|
||||
});
|
||||
});
|
||||
|
||||
it('Test table with simple filter', () => {
|
||||
const metrics = ['count'];
|
||||
const filters = [SIMPLE_FILTER];
|
||||
|
||||
const formData = { ...VIZ_DEFAULTS, metrics, adhoc_filters: filters };
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Tests table number formatting with % in metric name', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
percent_metrics: PERCENT_METRIC,
|
||||
groupby: ['state'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: /GROUP BY.*state/i,
|
||||
chartSelector: 'table',
|
||||
});
|
||||
cy.get('td').contains(/\d*%/);
|
||||
});
|
||||
|
||||
it('Test row limit with server pagination toggle', () => {
|
||||
const serverPaginationSelector =
|
||||
'[data-test="server_pagination-header"] div.pull-left [type="checkbox"]';
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
});
|
||||
|
||||
// Enable server pagination
|
||||
cy.get(serverPaginationSelector).click();
|
||||
|
||||
// Click row limit control and select high value (200k)
|
||||
cy.get('div[aria-label="Row limit"]').click();
|
||||
|
||||
// Type 200000 and press enter to select the option
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('200000{enter}');
|
||||
|
||||
// Verify that there is no error tooltip when server pagination is enabled
|
||||
cy.get('[data-test="error-tooltip"]').should('not.exist');
|
||||
|
||||
// Disable server pagination
|
||||
cy.get(serverPaginationSelector).click();
|
||||
|
||||
// Verify error tooltip appears
|
||||
cy.get('[data-test="error-tooltip"]').should('be.visible');
|
||||
|
||||
// Trigger mouseover and verify tooltip text
|
||||
cy.get('[data-test="error-tooltip"]').trigger('mouseover');
|
||||
|
||||
// Verify tooltip content
|
||||
cy.get('.ant-tooltip-inner').should('be.visible');
|
||||
cy.get('.ant-tooltip-inner').should(
|
||||
'contain',
|
||||
'Server pagination needs to be enabled for values over',
|
||||
);
|
||||
|
||||
// Hide the tooltip by adding display:none style
|
||||
cy.get('.ant-tooltip').invoke('attr', 'style', 'display: none');
|
||||
|
||||
// Enable server pagination again
|
||||
cy.get(serverPaginationSelector).click();
|
||||
|
||||
cy.get('[data-test="error-tooltip"]').should('not.exist');
|
||||
|
||||
cy.get('div[aria-label="Row limit"]').click();
|
||||
|
||||
// Type 1000000
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('1000000');
|
||||
|
||||
// Wait for 1 second
|
||||
cy.wait(1000);
|
||||
|
||||
// Press enter
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('{enter}');
|
||||
|
||||
// Wait for error tooltip to appear and verify its content
|
||||
cy.get('[data-test="error-tooltip"]')
|
||||
.should('be.visible')
|
||||
.trigger('mouseover');
|
||||
|
||||
// Wait for tooltip content and verify
|
||||
cy.get('.ant-tooltip-inner').should('exist');
|
||||
cy.get('.ant-tooltip-inner').should('be.visible');
|
||||
|
||||
// Verify tooltip content separately
|
||||
cy.get('.ant-tooltip-inner').should('contain', 'Value cannot exceed');
|
||||
});
|
||||
|
||||
it('Test sorting with server pagination enabled', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
groupby: ['name'],
|
||||
row_limit: 100000,
|
||||
server_pagination: true, // Enable server pagination
|
||||
});
|
||||
|
||||
// Wait for the initial data load
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get the first column header (name)
|
||||
cy.get('.chart-container th').contains('name').as('nameHeader');
|
||||
|
||||
// Click to sort ascending
|
||||
cy.get('@nameHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Verify first row starts with 'A'
|
||||
cy.get('.chart-container td:first').invoke('text').should('match', /^[Aa]/);
|
||||
|
||||
// Click again to sort descending
|
||||
cy.get('@nameHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Verify first row starts with 'Z'
|
||||
cy.get('.chart-container td:first').invoke('text').should('match', /^[Zz]/);
|
||||
|
||||
// Test numeric sorting
|
||||
cy.get('.chart-container th').contains('COUNT').as('countHeader');
|
||||
|
||||
// Click to sort ascending by count
|
||||
cy.get('@countHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get first two count values and verify ascending order
|
||||
cy.get('.chart-container td:nth-child(2)').then($cells => {
|
||||
const first = parseFloat($cells[0].textContent || '0');
|
||||
const second = parseFloat($cells[1].textContent || '0');
|
||||
expect(first).to.be.at.most(second);
|
||||
});
|
||||
|
||||
// Click again to sort descending
|
||||
cy.get('@countHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get first two count values and verify descending order
|
||||
cy.get('.chart-container td:nth-child(2)').then($cells => {
|
||||
const first = parseFloat($cells[0].textContent || '0');
|
||||
const second = parseFloat($cells[1].textContent || '0');
|
||||
expect(first).to.be.at.least(second);
|
||||
});
|
||||
});
|
||||
|
||||
it('Test search with server pagination enabled', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
groupby: ['name', 'state'],
|
||||
row_limit: 100000,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
});
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
const searchInputSelector = '.dt-global-filter input';
|
||||
|
||||
// Basic search test
|
||||
cy.get(searchInputSelector).should('be.visible');
|
||||
|
||||
cy.get(searchInputSelector).type('John');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/John/i);
|
||||
});
|
||||
|
||||
// Clear and test case-insensitive search
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get(searchInputSelector).type('mary');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/Mary/i);
|
||||
});
|
||||
|
||||
// Test special characters
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.get(searchInputSelector).type('Nicole');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/Nicole/i);
|
||||
});
|
||||
|
||||
// Test no results
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.get(searchInputSelector).type('XYZ123');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container').contains('No records found');
|
||||
|
||||
// Test column-specific search
|
||||
cy.get('.search-select').should('be.visible');
|
||||
|
||||
cy.get('.search-select').click();
|
||||
|
||||
cy.get('.ant-select-dropdown').should('be.visible');
|
||||
|
||||
cy.get('.ant-select-item-option').contains('state').should('be.visible');
|
||||
|
||||
cy.get('.ant-select-item-option').contains('state').click();
|
||||
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.get(searchInputSelector).type('CA');
|
||||
|
||||
cy.wait('@chartData');
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('td[aria-labelledby="header-state"]').should('be.visible');
|
||||
|
||||
cy.get('td[aria-labelledby="header-state"]')
|
||||
.first()
|
||||
.should('contain', 'CA');
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* 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 { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
|
||||
|
||||
describe('Visualization > Time TableViz', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const VIZ_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'time_table' };
|
||||
|
||||
it('Test time series table multiple metrics last year total', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC, 'count'],
|
||||
column_collection: [
|
||||
{
|
||||
key: '9g4K-B-YL',
|
||||
label: 'Last Year',
|
||||
colType: 'time',
|
||||
timeLag: '1',
|
||||
comparisonType: 'value',
|
||||
},
|
||||
],
|
||||
url: '',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@getJson',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
cy.get('[data-test="time-table"]').within(() => {
|
||||
cy.get('span').contains('Sum(num)');
|
||||
cy.get('span').contains('COUNT(*)');
|
||||
});
|
||||
});
|
||||
|
||||
it('Test time series table metric and group by last year total', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC],
|
||||
groupby: ['gender'],
|
||||
column_collection: [
|
||||
{
|
||||
key: '9g4K-B-YL',
|
||||
label: 'Last Year',
|
||||
colType: 'time',
|
||||
timeLag: '1',
|
||||
comparisonType: 'value',
|
||||
},
|
||||
],
|
||||
url: '',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@getJson',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
cy.get('[data-test="time-table"]').within(() => {
|
||||
cy.get('td').contains('boy');
|
||||
cy.get('td').contains('girl');
|
||||
});
|
||||
});
|
||||
|
||||
it('Test time series various time columns', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC, 'count'],
|
||||
column_collection: [
|
||||
{ key: 'LHHNPhamU', label: 'Current', colType: 'time', timeLag: 0 },
|
||||
{
|
||||
key: '9g4K-B-YL',
|
||||
label: 'Last Year',
|
||||
colType: 'time',
|
||||
timeLag: '1',
|
||||
comparisonType: 'value',
|
||||
},
|
||||
{
|
||||
key: 'JVZXtNu7_',
|
||||
label: 'YoY',
|
||||
colType: 'time',
|
||||
timeLag: 1,
|
||||
comparisonType: 'perc',
|
||||
d3format: '%',
|
||||
},
|
||||
{ key: 'tN5Gba36u', label: 'Trend', colType: 'spark' },
|
||||
],
|
||||
url: '',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@getJson',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
cy.get('[data-test="time-table"]').within(() => {
|
||||
cy.get('th').contains('Current');
|
||||
cy.get('th').contains('Last Year');
|
||||
cy.get('th').contains('YoY');
|
||||
cy.get('th').contains('Trend');
|
||||
|
||||
cy.get('span').contains('%');
|
||||
cy.get('svg')
|
||||
.first()
|
||||
.then(charts => {
|
||||
const firstChart = charts[0];
|
||||
expect(firstChart.clientWidth).greaterThan(0);
|
||||
expect(firstChart.clientHeight).greaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
describe('Visualization > World Map', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const WORLD_MAP_FORM_DATA = {
|
||||
datasource: '2__table',
|
||||
viz_type: 'world_map',
|
||||
slice_id: 45,
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '2014-01-01 : 2014-01-02',
|
||||
entity: 'country_code',
|
||||
country_fieldtype: 'cca3',
|
||||
metric: 'sum__SP_RUR_TOTL_ZS',
|
||||
adhoc_filters: [],
|
||||
row_limit: 50000,
|
||||
show_bubbles: true,
|
||||
secondary_metric: 'sum__SP_POP_TOTL',
|
||||
max_bubble_size: '25',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
|
||||
}
|
||||
|
||||
it('should work with ad-hoc metric', () => {
|
||||
verify(WORLD_MAP_FORM_DATA);
|
||||
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 206);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...WORLD_MAP_FORM_DATA,
|
||||
metric: 'count',
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: '==',
|
||||
comparator: 'South Asia',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_8aqxcf5co1a_x7lm2d1fq0l',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 8);
|
||||
});
|
||||
|
||||
it('should hide bubbles when told so', () => {
|
||||
verify({
|
||||
...WORLD_MAP_FORM_DATA,
|
||||
show_bubbles: false,
|
||||
});
|
||||
cy.get('.slice_container').then(containers => {
|
||||
expect(
|
||||
containers[0].querySelectorAll('.bubbles circle.datamaps-bubble')
|
||||
.length,
|
||||
).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(WORLD_MAP_FORM_DATA);
|
||||
|
||||
cy.get('.Control[data-test="linear_color_scheme"]').scrollIntoView();
|
||||
cy.get(
|
||||
'.Control[data-test="linear_color_scheme"] input[type="search"]',
|
||||
).focus();
|
||||
cy.focused().type('greens{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="linear_color_scheme"] .ant-select-selection-item [data-test="greens"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@
|
||||
// oxlint versions (not actually enforced). Documented here for future
|
||||
// maintainers — if/when oxlint adds them, re-enable in the relevant
|
||||
// plugin section above.
|
||||
// import: newline-after-import, no-extraneous-dependencies,
|
||||
// import: no-extraneous-dependencies,
|
||||
// no-import-module-exports, no-relative-packages,
|
||||
// no-unresolved, no-useless-path-segments
|
||||
// react: default-props-match-prop-types, destructuring-assignment,
|
||||
@@ -47,7 +47,6 @@
|
||||
// forbid-prop-types, function-component-definition,
|
||||
// jsx-no-bind, jsx-uses-vars, no-access-state-in-setstate,
|
||||
// no-deprecated, no-did-update-set-state, no-typos,
|
||||
// no-unstable-nested-components,
|
||||
// no-unused-class-component-methods, no-unused-prop-types,
|
||||
// no-unused-state, prefer-stateless-function, prop-types,
|
||||
// require-default-props, sort-comp, static-property-placement
|
||||
@@ -137,6 +136,7 @@
|
||||
"import/no-self-import": "error",
|
||||
"import/no-cycle": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"import/newline-after-import": "error",
|
||||
|
||||
// === React plugin rules ===
|
||||
"react/jsx-filename-extension": [
|
||||
@@ -184,6 +184,10 @@
|
||||
"error",
|
||||
{ "button": true, "submit": true, "reset": false }
|
||||
],
|
||||
// TODO: Graduate to "error" after cleanup pass — ~150 violations
|
||||
// across the codebase require hoisting nested component definitions
|
||||
// out of their parent render functions.
|
||||
"react/no-unstable-nested-components": "warn",
|
||||
|
||||
// === React Hooks rules ===
|
||||
// TODO: Fix conditional hook usage and anonymous component issues
|
||||
@@ -271,7 +275,10 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["plugins/plugin-chart-table/src/TableChart.tsx", "plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"],
|
||||
"files": [
|
||||
"plugins/plugin-chart-table/src/TableChart.tsx",
|
||||
"plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"jsx-a11y/no-redundant-roles": "off"
|
||||
}
|
||||
|
||||
104
superset-frontend/package-lock.json
generated
104
superset-frontend/package-lock.json
generated
@@ -31,7 +31,7 @@
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.1",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -121,7 +121,7 @@
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.6.1",
|
||||
"react-arborist": "^3.7.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.9.0",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -229,7 +229,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -284,14 +284,14 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.10",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
@@ -3958,9 +3958,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@googleapis/sheets": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
|
||||
"integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==",
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.2.tgz",
|
||||
"integrity": "sha512-b1tBlMcfvNEziM4DZCikLOc9iqSlgCK1e5bMKtNQIADRXr1CQmbkHV3ZBVvTsFsjLErgihqO58Itn/kzCnSZ0A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"googleapis-common": "^8.0.0"
|
||||
@@ -12568,9 +12568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.9.0.tgz",
|
||||
"integrity": "sha512-h57mL/TsOrhimvHs6KQQLZO1T+D7FQyx+7WS17p9vV228qxmZatF0IgEXMyERWthm1QL7fAB6cEMBCtujSVbyw==",
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.10.0.tgz",
|
||||
"integrity": "sha512-uhPq0oJHk2/W2Hn6vLaNmbUUgNPPj0FINHISxfs9hqS2Hpv/TVzQFsnbxul1FJEa+YQe1Qebou2esDphwzIuKg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -13164,22 +13164,13 @@
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint-scope": {
|
||||
"version": "3.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
|
||||
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint": "*",
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -17218,9 +17209,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
|
||||
"version": "2.10.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
|
||||
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -21827,14 +21818,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -24541,9 +24532,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/geostyler": {
|
||||
"version": "18.5.1",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.1.tgz",
|
||||
"integrity": "sha512-5+vLuDo1oR4QQTnrfkccIQSe3qEn0ytV9dLiFFhnxhPdziv/Wp3vKNhJZ37MUF5yIj2ISWZ+q/VmSNH6ifvWpg==",
|
||||
"version": "18.6.0",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.6.0.tgz",
|
||||
"integrity": "sha512-q8x5V4yJlTFOIe5LSvhEHd62MrMJq1YXWJVTeAG2TUMgOudjrcglXDqKtFYtEdWHeORH6TXz7q+m6cg3RlZqAg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
@@ -32537,9 +32528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
|
||||
"integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -40280,9 +40271,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-arborist": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.6.1.tgz",
|
||||
"integrity": "sha512-h2/sPz6PXL79h7mOWjCA6Y5WNUKmA0kL8Uh6RYZQbYk7UOFBd86Jeoga4RjHMBYpOWpBPYrOJOE3HbIPUETp8w==",
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.7.0.tgz",
|
||||
"integrity": "sha512-gh2SoO0eXQVSP6zxXMGqFeXF+l2uabDGBVn0+RKqy/s7mrG5xGnfM5mhyB67cMVobC3vWYLqe6HGh7ZEZadW/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-dnd": "^14.0.3",
|
||||
@@ -44459,9 +44450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -45223,9 +45214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.4.10",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
|
||||
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
|
||||
"version": "29.4.11",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz",
|
||||
"integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -47366,13 +47357,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.106.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz",
|
||||
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
|
||||
"version": "5.107.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.1.tgz",
|
||||
"integrity": "sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@webassemblyjs/ast": "^1.14.1",
|
||||
@@ -47382,20 +47372,20 @@
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.20.0",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"enhanced-resolve": "^5.21.4",
|
||||
"es-module-lexer": "^2.1.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"loader-runner": "^4.3.1",
|
||||
"loader-runner": "^4.3.2",
|
||||
"mime-db": "^1.54.0",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.17",
|
||||
"terser-webpack-plugin": "^5.5.0",
|
||||
"watchpack": "^2.5.1",
|
||||
"webpack-sources": "^3.3.4"
|
||||
"webpack-sources": "^3.4.1"
|
||||
},
|
||||
"bin": {
|
||||
"webpack": "bin/webpack.js"
|
||||
@@ -48910,7 +48900,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -50095,7 +50085,7 @@
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"build-instrumented": "cross-env NODE_ENV=production BABEL_ENV=instrumented webpack --mode=production --color",
|
||||
"build-storybook": "storybook build",
|
||||
"build-translation": "scripts/po2json.sh",
|
||||
"translations:build-index": "python3 ../scripts/translations/build_translation_index.py",
|
||||
"translations:backfill": "python3 ../scripts/translations/backfill_po.py",
|
||||
"bundle-stats": "cross-env BUNDLE_ANALYZER=true npm run build && npx open-cli ../superset/static/stats/statistics.html",
|
||||
"clear-npm": "mkdir -p /tmp/empty && rsync -a --delete /tmp/empty/ node_modules/ && rmdir node_modules /tmp/empty",
|
||||
"core:cover": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage --coverageThreshold='{\"global\":{\"statements\":100,\"branches\":100,\"functions\":100,\"lines\":100}}' --collectCoverageFrom='[\"packages/**/src/**/*.{js,ts}\", \"!packages/superset-core/**/*\"]' packages",
|
||||
@@ -112,7 +114,7 @@
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.1",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -177,7 +179,7 @@
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -202,7 +204,7 @@
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.6.1",
|
||||
"react-arborist": "^3.7.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -276,7 +278,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.9.0",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -310,7 +312,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -365,14 +367,14 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.10",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -48,6 +48,7 @@ import NoResultsComponent from './NoResultsComponent';
|
||||
import { isMatrixifyEnabled } from '../types/matrixify';
|
||||
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
|
||||
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
|
||||
|
||||
export type WrapperProps = Dimension & {
|
||||
|
||||
@@ -897,6 +897,476 @@ test('fires onChange when pasting a selection', async () => {
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('replaces cached options with search results instead of merging', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Search Match');
|
||||
});
|
||||
|
||||
test('shows all options when filterOption is false', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Base ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = Array.from({ length: 5 }, (_, i) => ({
|
||||
label: `Server ${i}`,
|
||||
value: 100 + i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) =>
|
||||
search === ''
|
||||
? { data: page0Data, totalCount: 100 }
|
||||
: { data: searchData, totalCount: 5 },
|
||||
);
|
||||
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
filterOption={false}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('zzz_no_match');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(5);
|
||||
expect(options[0]).toHaveTextContent('Server 0');
|
||||
});
|
||||
|
||||
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: [], totalCount: 0 };
|
||||
});
|
||||
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('newval');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('newval');
|
||||
// Stale page-0 options must not bleed through.
|
||||
expect(screen.queryByText('Option 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restores base options when search is cleared', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Search Match');
|
||||
|
||||
// type() clears the input before typing, so passing '' clears the search.
|
||||
await type('');
|
||||
await waitFor(async () => {
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
});
|
||||
expect(options[0]).toHaveTextContent('Option 0');
|
||||
expect(screen.queryByText('Search Match')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('replaces results when switching between two searches', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return {
|
||||
data: [{ label: `Match-${search}`, value: `v-${search}` }],
|
||||
totalCount: 1,
|
||||
};
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
|
||||
await type('beta');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('refetches a dropped search response when the same search is repeated', async () => {
|
||||
type OptionRow = { label: string; value: string | number };
|
||||
type PageResponse = { data: OptionRow[]; totalCount: number };
|
||||
// Resolves the in-flight loadOptions promise of the calling test.
|
||||
let resolveAlpha: ((value: PageResponse) => void) | null = null;
|
||||
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
|
||||
|
||||
const loadOptions = jest.fn((search: string) => {
|
||||
if (search === '') {
|
||||
return Promise.resolve<PageResponse>({
|
||||
data: page0Data,
|
||||
totalCount: 100,
|
||||
});
|
||||
}
|
||||
if (search === 'alpha') {
|
||||
// First call: hold the promise so it resolves only after beta returns.
|
||||
// Second call (after beta): resolve immediately so the cache MUST allow
|
||||
// a refetch.
|
||||
if (!resolveAlpha) {
|
||||
return new Promise<PageResponse>(resolve => {
|
||||
resolveAlpha = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve<PageResponse>({ data: alphaData, totalCount: 1 });
|
||||
}
|
||||
return Promise.resolve<PageResponse>({ data: betaData, totalCount: 1 });
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10));
|
||||
// alpha's promise is held; switch to beta which resolves first.
|
||||
await type('beta');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
|
||||
// Release the stale alpha response. It must be dropped — its key must not
|
||||
// be cached, or returning to "alpha" later would short-circuit the fetch.
|
||||
resolveAlpha!({ data: alphaData, totalCount: 1 });
|
||||
await waitFor(async () => {
|
||||
// Beta is still showing because alpha's response was dropped.
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
|
||||
// Returning to "alpha" must re-trigger the fetch (cache wasn't poisoned).
|
||||
const callsBeforeAlphaReturn = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
await type('alpha');
|
||||
await waitFor(() => {
|
||||
const callsAfter = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBeforeAlphaReturn);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps loading indicator while a newer request is in flight after a stale response is dropped', async () => {
|
||||
// Regression for the P2 race: the `.finally` block that clears isLoading
|
||||
// must not fire when a stale (dropped) response resolves while a newer
|
||||
// request is still in flight. Otherwise the spinner disappears mid-search
|
||||
// and the undebounced scroll-pagination handler can fire against stale
|
||||
// totalCount before page 0 of the active search lands.
|
||||
type OptionRow = { label: string; value: string | number };
|
||||
type PageResponse = { data: OptionRow[]; totalCount: number };
|
||||
// Initialized to no-op so the finally block can always call them, even if
|
||||
// an assertion in the try throws before the corresponding mock ran.
|
||||
let resolveAlpha: (value: PageResponse) => void = () => {};
|
||||
let resolveBeta: (value: PageResponse) => void = () => {};
|
||||
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
|
||||
|
||||
const loadOptions = jest.fn((search: string) => {
|
||||
if (search === '') {
|
||||
return Promise.resolve<PageResponse>({
|
||||
data: page0Data,
|
||||
totalCount: 100,
|
||||
});
|
||||
}
|
||||
if (search === 'alpha') {
|
||||
return new Promise<PageResponse>(resolve => {
|
||||
resolveAlpha = resolve;
|
||||
});
|
||||
}
|
||||
return new Promise<PageResponse>(resolve => {
|
||||
resolveBeta = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const isSpinnerVisible = (): boolean =>
|
||||
Boolean(document.querySelector('.ant-select-arrow .ant-spin'));
|
||||
|
||||
try {
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
// Type 'alpha' — alpha fetch is held, loading should be true.
|
||||
await type('alpha');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10),
|
||||
);
|
||||
await waitFor(() => expect(isSpinnerVisible()).toBe(true));
|
||||
|
||||
// Type 'beta' — beta fetch is also held; both are in flight.
|
||||
await type('beta');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('beta', 0, 10),
|
||||
);
|
||||
expect(isSpinnerVisible()).toBe(true);
|
||||
|
||||
// Release the stale alpha response. It is dropped at the early-return
|
||||
// (search !== inputValueRef.current), but the in-flight counter is still
|
||||
// non-zero because beta is pending — spinner must stay visible.
|
||||
resolveAlpha({ data: alphaData, totalCount: 1 });
|
||||
// Yield a microtask so alpha's .then/.finally runs, then re-assert.
|
||||
await Promise.resolve();
|
||||
expect(isSpinnerVisible()).toBe(true);
|
||||
|
||||
// Release beta. Now the in-flight counter drops to 0 and the spinner
|
||||
// clears.
|
||||
resolveBeta({ data: betaData, totalCount: 1 });
|
||||
await waitFor(() => expect(isSpinnerVisible()).toBe(false));
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
} finally {
|
||||
// Defensive: never leave a held promise that could hang a parallel worker
|
||||
// if an assertion above threw. Promise resolve is idempotent.
|
||||
resolveAlpha({ data: alphaData, totalCount: 1 });
|
||||
resolveBeta({ data: betaData, totalCount: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
test('re-shows search results when the same search term is repeated after a clear', async () => {
|
||||
// Regression: a prior fix cached search responses' totalCount in
|
||||
// fetchedQueries. After restore-on-clear had replaced selectOptions with
|
||||
// the base list, re-typing a previously-resolved term would hit the cache
|
||||
// short-circuit and leave selectOptions stale (empty / base-only).
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
// totalCount > data.length so allValuesLoaded stays false and the
|
||||
// search path is not bypassed by the "all loaded" short-circuit.
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: alphaData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
|
||||
await type('');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const callsBefore = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
await type('alpha');
|
||||
await waitFor(() => {
|
||||
const callsAfter = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
});
|
||||
|
||||
test('appends page>1 results during an active search and discards them when search changes', async () => {
|
||||
// Covers the production branch `else { mergeData(data) }` in fetchPage that
|
||||
// fires when search is non-empty AND page > 0 — i.e. user scrolled within
|
||||
// a multi-page search result. Switching to a new search must replace, not
|
||||
// retain, the prior search's accumulated pages.
|
||||
type OptionRow = { label: string; value: string | number };
|
||||
const pageSize = 5;
|
||||
const aliceData: OptionRow[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
label: `Alice-${i}`,
|
||||
value: `a${i}`,
|
||||
}));
|
||||
const alicePage1: OptionRow[] = Array.from({ length: 3 }, (_, i) => ({
|
||||
label: `Alice-${i + 5}`,
|
||||
value: `a${i + 5}`,
|
||||
}));
|
||||
const bobData: OptionRow[] = [{ label: 'Bob-0', value: 'b0' }];
|
||||
|
||||
const loadOptions = jest.fn(
|
||||
async (
|
||||
search: string,
|
||||
page: number,
|
||||
): Promise<{
|
||||
data: OptionRow[];
|
||||
totalCount: number;
|
||||
}> => {
|
||||
if (search === '') {
|
||||
return { data: [], totalCount: 100 };
|
||||
}
|
||||
if (search === 'alice') {
|
||||
if (page === 0) return { data: aliceData, totalCount: 8 };
|
||||
return { data: alicePage1, totalCount: 8 };
|
||||
}
|
||||
return { data: bobData, totalCount: 1 };
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} pageSize={pageSize} options={loadOptions} />,
|
||||
);
|
||||
await open();
|
||||
|
||||
await type('alice');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('alice', 0, pageSize),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(5);
|
||||
});
|
||||
// Wait for loading to finish so handlePagination's `!isLoading` gate is
|
||||
// open before we fire scroll.
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('.ant-select-arrow .ant-spin')).toBeNull(),
|
||||
);
|
||||
|
||||
// Trigger pagination by dispatching a scroll event on the virtual-list
|
||||
// scroll container. jsdom returns 0 for layout properties by default, so
|
||||
// override the relevant ones before firing scroll. rc-virtual-list reads
|
||||
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
|
||||
// then forwards to onPopupScroll (handlePagination here).
|
||||
const holder = document.querySelector(
|
||||
'.rc-virtual-list-holder',
|
||||
) as HTMLElement | null;
|
||||
if (!holder) throw new Error('virtual-list holder not rendered');
|
||||
Object.defineProperty(holder, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => 1000,
|
||||
});
|
||||
Object.defineProperty(holder, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => 200,
|
||||
});
|
||||
Object.defineProperty(holder, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 200,
|
||||
});
|
||||
Object.defineProperty(holder, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => 900,
|
||||
set: () => {},
|
||||
});
|
||||
fireEvent.scroll(holder);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('alice', 1, pageSize),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
// Page 0 (5) + page 1 (3) merged
|
||||
expect(options).toHaveLength(8);
|
||||
});
|
||||
|
||||
// Switching to a new search must replace the accumulated pages, not retain
|
||||
// them.
|
||||
await type('bob');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('bob', 0, pageSize),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Bob-0');
|
||||
});
|
||||
expect(screen.queryByText('Alice-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Alice-7')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not duplicate options when using numeric values', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
|
||||
@@ -160,6 +160,13 @@ const AsyncSelect = forwardRef(
|
||||
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
|
||||
const selectValueRef = useRef(selectValue);
|
||||
const fetchedQueries = useRef(new Map<string, number>());
|
||||
const initialOptionsRef = useRef<SelectOptionsType>(EMPTY_OPTIONS);
|
||||
const inputValueRef = useRef('');
|
||||
// Counts fetches whose `.finally` has not yet run. Loading is cleared only
|
||||
// when this drops to 0, so a stale response (which returns early without
|
||||
// updating selectOptions) cannot flip the spinner off while a newer
|
||||
// request is still pending.
|
||||
const inFlightFetchesRef = useRef(0);
|
||||
const mappedMode = isSingleMode ? undefined : 'multiple';
|
||||
const allowFetch = !fetchOnlyOnSearch || inputValue;
|
||||
const [maxTagCount, setMaxTagCount] = useState(
|
||||
@@ -183,6 +190,10 @@ const AsyncSelect = forwardRef(
|
||||
selectValueRef.current = selectValue;
|
||||
}, [selectValue]);
|
||||
|
||||
useEffect(() => {
|
||||
inputValueRef.current = inputValue;
|
||||
}, [inputValue]);
|
||||
|
||||
const sortSelectedFirst = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
sortSelectedFirstHelper(a, b, selectValueRef.current),
|
||||
@@ -333,22 +344,78 @@ const AsyncSelect = forwardRef(
|
||||
setIsLoading(true);
|
||||
|
||||
const fetchOptions = options as SelectOptionsPagePromise;
|
||||
inFlightFetchesRef.current += 1;
|
||||
fetchOptions(search, page, pageSize)
|
||||
.then(({ data, totalCount }: SelectOptionsTypePage) => {
|
||||
const mergedData = mergeData(data);
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
setTotalCount(totalCount);
|
||||
if (
|
||||
!fetchOnlyOnSearch &&
|
||||
search === '' &&
|
||||
mergedData.length >= totalCount
|
||||
) {
|
||||
setAllValuesLoaded(true);
|
||||
// Drop responses whose search arg no longer matches the user's
|
||||
// current input — otherwise a slow base fetch can land after a
|
||||
// search fetch (or a stale debounced search after a clear) and
|
||||
// re-pollute the dropdown via mergeData / search-replace. Search
|
||||
// responses are never cached in fetchedQueries: the cache stores
|
||||
// only totalCount, so a cache hit would short-circuit the fetch
|
||||
// and leave selectOptions stale (e.g. after restore-on-clear).
|
||||
// Re-issuing the search is cheap and correct.
|
||||
const matchesCurrentSearch = inputValueRef.current === search;
|
||||
if (search && !matchesCurrentSearch) {
|
||||
return;
|
||||
}
|
||||
if (!search) {
|
||||
// Accumulate base pages in a ref independent of selectOptions
|
||||
// (during an active search, selectOptions holds search results
|
||||
// and is not a safe accumulator). The accumulator is kept up
|
||||
// to date even when this response landed during a search, so
|
||||
// restore-on-clear has a complete snapshot. We don't sort here
|
||||
// — restore-on-clear sorts a copy at consumption time, and the
|
||||
// live selectOptions path below goes through mergeData which
|
||||
// sorts there. Sorting here too would double the per-page sort
|
||||
// cost on large cached option sets.
|
||||
const dataValues = new Set(data.map(opt => opt.value));
|
||||
const accumulated = initialOptionsRef.current
|
||||
.filter(opt => !dataValues.has(opt.value))
|
||||
.concat(data);
|
||||
initialOptionsRef.current = accumulated;
|
||||
if (!fetchOnlyOnSearch && accumulated.length >= totalCount) {
|
||||
setAllValuesLoaded(true);
|
||||
}
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
if (matchesCurrentSearch) {
|
||||
// No active search — push to live selectOptions and update
|
||||
// totalCount. When matchesCurrentSearch is false, the user
|
||||
// is mid-search; leave the search's totalCount in place so
|
||||
// pagination math stays correct.
|
||||
mergeData(data);
|
||||
setTotalCount(totalCount);
|
||||
}
|
||||
} else if (page === 0) {
|
||||
// Replace cached options with server results; preserve
|
||||
// optimistic isNewOption entries inserted by handleOnSearch
|
||||
// so allowNewOptions users can still click the value they
|
||||
// typed when the server returns no match.
|
||||
setSelectOptions(prevOptions => {
|
||||
const dataValues = new Set(data.map(opt => opt.value));
|
||||
const preservedNew = prevOptions.filter(
|
||||
opt => opt.isNewOption && !dataValues.has(opt.value),
|
||||
);
|
||||
return preservedNew
|
||||
.concat(data)
|
||||
.sort(sortComparatorForNoSearch);
|
||||
});
|
||||
setTotalCount(totalCount);
|
||||
} else {
|
||||
// page > 0 during an active search — append normally.
|
||||
mergeData(data);
|
||||
setTotalCount(totalCount);
|
||||
}
|
||||
})
|
||||
.catch(internalOnError)
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
inFlightFetchesRef.current = Math.max(
|
||||
0,
|
||||
inFlightFetchesRef.current - 1,
|
||||
);
|
||||
if (inFlightFetchesRef.current === 0) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
@@ -358,6 +425,7 @@ const AsyncSelect = forwardRef(
|
||||
internalOnError,
|
||||
options,
|
||||
pageSize,
|
||||
sortComparatorForNoSearch,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -500,6 +568,7 @@ const AsyncSelect = forwardRef(
|
||||
fetchedQueries.current.clear();
|
||||
setAllValuesLoaded(false);
|
||||
setSelectOptions(EMPTY_OPTIONS);
|
||||
initialOptionsRef.current = EMPTY_OPTIONS;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -514,16 +583,36 @@ const AsyncSelect = forwardRef(
|
||||
[debouncedFetchPage],
|
||||
);
|
||||
|
||||
const previousInputValue = usePrevious(inputValue, '');
|
||||
useEffect(() => {
|
||||
if (loadingEnabled && allowFetch) {
|
||||
// trigger fetch every time inputValue changes
|
||||
if (inputValue) {
|
||||
debouncedFetchPage(inputValue, 0);
|
||||
} else {
|
||||
// Cancel any pending debounced search fetch so it can't fire after
|
||||
// we've already restored the base list.
|
||||
debouncedFetchPage.cancel();
|
||||
// On returning to empty input after a search, restore the cached
|
||||
// base options so the dropdown shows the original page-0 list
|
||||
// instead of the stale search results.
|
||||
if (previousInputValue && initialOptionsRef.current.length > 0) {
|
||||
setSelectOptions(
|
||||
[...initialOptionsRef.current].sort(sortComparatorForNoSearch),
|
||||
);
|
||||
}
|
||||
fetchPage('', 0);
|
||||
}
|
||||
}
|
||||
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
|
||||
}, [
|
||||
loadingEnabled,
|
||||
fetchPage,
|
||||
allowFetch,
|
||||
inputValue,
|
||||
previousInputValue,
|
||||
debouncedFetchPage,
|
||||
sortComparatorForNoSearch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading !== undefined && loading !== isLoading) {
|
||||
@@ -531,7 +620,11 @@ const AsyncSelect = forwardRef(
|
||||
}
|
||||
}, [isLoading, loading]);
|
||||
|
||||
const clearCache = () => fetchedQueries.current.clear();
|
||||
const clearCache = () => {
|
||||
fetchedQueries.current.clear();
|
||||
initialOptionsRef.current = EMPTY_OPTIONS;
|
||||
setAllValuesLoaded(false);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
||||
@@ -211,6 +211,10 @@ export const handleFilterOptionHelper = (
|
||||
return filterOption(search, option);
|
||||
}
|
||||
|
||||
if (filterOption === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterOption) {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
if (optionFilterProps?.length) {
|
||||
|
||||
@@ -371,3 +371,37 @@ test('should handle large datasets with pagination', () => {
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should reset to first page when data reduces below current page', async () => {
|
||||
// Start with 30 items, 10 per page = 3 pages
|
||||
const initialData = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: i,
|
||||
age: 20 + i,
|
||||
name: `Person ${i}`,
|
||||
}));
|
||||
|
||||
const props = {
|
||||
...mockedProps,
|
||||
data: initialData,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TableView {...props} />);
|
||||
|
||||
// Navigate to page 3 (last page)
|
||||
const page3 = screen.getByRole('listitem', { name: '3' });
|
||||
await userEvent.click(page3);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('21-30 of 30')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reduce data to only 5 items (fewer than current page would show)
|
||||
const reducedData = initialData.slice(0, 5);
|
||||
rerender(<TableView {...props} data={reducedData} />);
|
||||
|
||||
// Should reset to page 1 since page 3 no longer exists
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1-5 of 5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,6 +246,21 @@ const RawTableView = ({
|
||||
}
|
||||
}, [initialSortBy, onServerPagination, serverPagination, sortBy]);
|
||||
|
||||
// Reset to first page when current page exceeds available pages
|
||||
// (e.g., when filtering reduces the data below the current page)
|
||||
const pageCount = Math.ceil(data.length / effectivePageSize);
|
||||
useEffect(() => {
|
||||
if (
|
||||
withPagination &&
|
||||
!serverPagination &&
|
||||
!loading &&
|
||||
pageIndex > pageCount - 1 &&
|
||||
pageCount > 0
|
||||
) {
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [withPagination, serverPagination, loading, pageIndex, pageCount]);
|
||||
|
||||
return (
|
||||
<TableViewStyles {...props} ref={tableRef}>
|
||||
<TableCollection
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface ChartDataResponseResult {
|
||||
// TODO(hainenber): define proper type for below attributes
|
||||
rejected_filters?: any[];
|
||||
applied_filters?: any[];
|
||||
warning?: string | null;
|
||||
/**
|
||||
* Detected ISO 4217 currency code when AUTO mode is used.
|
||||
* Returns the currency code if all filtered data contains a single currency,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"acorn": "^8.16.0",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -27,7 +27,7 @@ jest.mock('../../DeckGLContainer', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../../factory', () => ({
|
||||
createDeckGLComponent: jest.fn(() => () => null),
|
||||
createCategoricalDeckGLComponent: jest.fn(() => () => null),
|
||||
GetLayerType: {},
|
||||
}));
|
||||
|
||||
@@ -53,6 +53,14 @@ const mockPayload = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockLayerParams = {
|
||||
onContextMenu: jest.fn(),
|
||||
filterState: undefined,
|
||||
setDataMask: jest.fn(),
|
||||
setTooltip: jest.fn(),
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('getLayer uses line_width_unit from formData', () => {
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
@@ -117,3 +125,518 @@ test('getPoints extracts points from path data', () => {
|
||||
expect(points[0]).toEqual([0, 0]);
|
||||
expect(points[2]).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
test('Fixed width mode returns constant width for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map(d => d.width);
|
||||
|
||||
widths.forEach(width => {
|
||||
expect(width).toBe(widths[0]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fixed width mode applies multiplier correctly', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width_multiplier: 3,
|
||||
min_width: 1,
|
||||
max_width: 100,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(15);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces minimum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 2,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces maximum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('Fixed width mode defaults width to 1 when no width is provided', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: undefined,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(1);
|
||||
});
|
||||
|
||||
test('Metric mode normalizes widths proportionally between min and max bounds', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map((d: any) => d.width);
|
||||
|
||||
expect(widths[0]).toBeCloseTo(1);
|
||||
expect(widths[1]).toBeCloseTo(10.5);
|
||||
expect(widths[2]).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Metric mode applies multiplier after normalization', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 2,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBeCloseTo(2);
|
||||
expect(data[1].width).toBe(20);
|
||||
});
|
||||
|
||||
test('Metric mode enforces bounds after multiplier', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 5,
|
||||
max_width: 15,
|
||||
line_width_multiplier: 10,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.width).toBeGreaterThanOrEqual(5);
|
||||
expect(d.width).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
test('Metric mode handles equal width values.', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBe(data[1].width);
|
||||
});
|
||||
|
||||
test('Metric mode handles null width values', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: null,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[1].width).toBe(1);
|
||||
expect(data[0].width).toBeCloseTo(1);
|
||||
expect(data[2].width).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Fixed color mode returns same color for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
color_picker: { r: 255, g: 100, b: 50, a: 1 },
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const expectedColor = [255, 100, 50, 255];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.color).toEqual(expectedColor);
|
||||
});
|
||||
});
|
||||
|
||||
test('Categorical mode preserves distinct colors for selected categories', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
cat_color: 'B',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
test('Breakpoint mode preserves colors assigned by addColor based on metric ranges', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 50,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
metric: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 75,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
@@ -21,13 +21,14 @@ import { PathLayer } from '@deck.gl/layers';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
import { commonLayerProps } from '../common';
|
||||
import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
||||
import { Point } from '../../types';
|
||||
import {
|
||||
createTooltipContent,
|
||||
CommonTooltipRows,
|
||||
} from '../../utilities/tooltipUtils';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
function setTooltipContent(formData: QueryFormData) {
|
||||
const defaultTooltipGenerator = (o: JsonObject) => (
|
||||
@@ -50,14 +51,69 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
emitCrossFilters,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const c = fd.color_picker;
|
||||
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
if (feature.color) {
|
||||
return { ...feature };
|
||||
}
|
||||
|
||||
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const color = [c.r, c.g, c.b, 255 * c.a];
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
// Variables for width scaling and normalization
|
||||
const minWidth = Number(fd.min_width) || 1; // defaulted to 1
|
||||
const maxWidth = Number(fd.max_width) || 20; // defaulted to 20
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1; // defaulted to 1
|
||||
|
||||
const widths = data.map((d: JsonObject) => d.width).filter(Number.isFinite);
|
||||
|
||||
// Metric or fixed value
|
||||
const isMetricWidth = isMetricValue(fd.line_width);
|
||||
|
||||
if (isMetricWidth) {
|
||||
// Get minimum and maximum widths in data set
|
||||
const minVal = widths.length > 0 ? Math.min(...widths) : minWidth;
|
||||
const maxVal = widths.length > 0 ? Math.max(...widths) : maxWidth;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
if (d.width == null) return { ...d, width: minWidth };
|
||||
|
||||
const normalized =
|
||||
maxVal === minVal ? 0.5 : (d.width - minVal) / (maxVal - minVal);
|
||||
|
||||
// Map within range of min + max
|
||||
let width = minWidth + normalized * (maxWidth - minWidth);
|
||||
|
||||
// Apply scaling multiplier
|
||||
width *= multiplier;
|
||||
|
||||
// Enforce minimum and maximum width bounds
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return { ...d, width };
|
||||
});
|
||||
} else {
|
||||
// Fixed width mode
|
||||
// Allows for use with legacy charts
|
||||
const fixedWidth =
|
||||
typeof fd.line_width === 'number'
|
||||
? fd.line_width
|
||||
: typeof fd.line_width === 'object' && fd.line_width?.type === 'fix'
|
||||
? Number(fd.line_width.value)
|
||||
: undefined;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
let width = (d.width ?? fixedWidth ?? 1) * multiplier;
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
return { ...d, width };
|
||||
});
|
||||
}
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -66,13 +122,15 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
|
||||
return new PathLayer({
|
||||
id: `path-layer-${fd.slice_id}` as const,
|
||||
getColor: (d: any) => d.color,
|
||||
getColor: (d: any) => d.color || [0, 0, 0, 255],
|
||||
getPath: (d: any) => d.path,
|
||||
getWidth: (d: any) => d.width,
|
||||
data,
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
...commonLayerProps({
|
||||
formData: fd,
|
||||
setTooltip,
|
||||
@@ -101,13 +159,23 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const minWidth = Number(fd.min_width) || 1;
|
||||
const maxWidth = Number(fd.max_width) || 20;
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1;
|
||||
const fixedColor = HIGHLIGHT_COLOR_ARRAY;
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
const baseWidth = Number.isFinite(feature.width) ? feature.width : 1;
|
||||
let width = baseWidth * multiplier;
|
||||
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width,
|
||||
color: fixedColor,
|
||||
};
|
||||
});
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -128,7 +196,13 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
export default createCategoricalDeckGLComponent(
|
||||
getLayer,
|
||||
getPoints,
|
||||
getHighlightLayer,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import buildQuery, { DeckPathFormData } from './buildQuery';
|
||||
|
||||
const baseFormData: DeckPathFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'deck_path',
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
row_limit: 100,
|
||||
};
|
||||
|
||||
test('Path buildQuery should not include metric when line_width is fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle numeric line_width value with fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing line_width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should include metric when line_width is metric type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'COUNT(*)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SQL metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'custom_width',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'SUM(weight) / COUNT(*)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SIMPLE metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'AVG(traffic)',
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'traffic' },
|
||||
aggregate: 'AVG' as const,
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle metric type with undefined value', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate width metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(weight)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Path buildQuery should preserve existing metrics when adding width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
expect(query.metrics).toContain('AVG(weight)');
|
||||
expect(query.metrics).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not modify existing metrics for fixed width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)', 'SUM(value)'],
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual(['COUNT(*)', 'SUM(value)']);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle undefined value in metric type gracefully', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
// Should not add anything when value is undefined
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width with undefined type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: undefined,
|
||||
value: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Dimension (categorical color) ───
|
||||
|
||||
test('Path buildQuery should include dimension column when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
dimension: 'route_type',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.columns).toContain('route_type');
|
||||
});
|
||||
|
||||
test('Path buildQuery should include breakpoint_metric when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using breakpoint metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate breakpoint metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(speed)'],
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle breakpoint_metric and line_width metric together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc breakpoint metric', () => {
|
||||
const adhocMetric = {
|
||||
label: 'avg_speed',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'AVG(speed_mph)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: adhocMetric,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing breakpoint_metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width and breakpoint_metrics together together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
js_columns: ['color'],
|
||||
tooltip_contents: ['name'],
|
||||
row_limit: 500,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
expect(query.columns).toContain('color');
|
||||
expect(query.columns).toContain('name');
|
||||
expect(query.row_limit).toBe(500);
|
||||
});
|
||||
@@ -19,10 +19,13 @@
|
||||
import {
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
getMetricLabel,
|
||||
SqlaFormData,
|
||||
QueryFormColumn,
|
||||
QueryFormMetric,
|
||||
} from '@superset-ui/core';
|
||||
import { addNullFilters, addTooltipColumnsToQuery } from '../buildQueryUtils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
export interface DeckPathFormData extends SqlaFormData {
|
||||
line_column?: string;
|
||||
@@ -32,10 +35,26 @@ export interface DeckPathFormData extends SqlaFormData {
|
||||
js_columns?: string[];
|
||||
tooltip_contents?: unknown[];
|
||||
tooltip_template?: string;
|
||||
line_width?:
|
||||
| string
|
||||
| { type?: 'fix' | 'metric'; value?: QueryFormMetric | number };
|
||||
line_width_multiplier?: number;
|
||||
min_width?: number;
|
||||
max_width?: number;
|
||||
dimension?: string;
|
||||
breakpoint_metric?: QueryFormMetric;
|
||||
}
|
||||
|
||||
export default function buildQuery(formData: DeckPathFormData) {
|
||||
const { line_column, metric, js_columns, tooltip_contents } = formData;
|
||||
const {
|
||||
line_column,
|
||||
metric,
|
||||
js_columns,
|
||||
tooltip_contents,
|
||||
line_width,
|
||||
dimension,
|
||||
breakpoint_metric,
|
||||
} = formData;
|
||||
|
||||
if (!line_column) {
|
||||
throw new Error('Line column is required for Path charts');
|
||||
@@ -46,7 +65,7 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
const columns = ensureIsArray(
|
||||
baseQueryObject.columns || [],
|
||||
) as QueryFormColumn[];
|
||||
const metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
let metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
const groupby = ensureIsArray(
|
||||
baseQueryObject.groupby || [],
|
||||
) as QueryFormColumn[];
|
||||
@@ -63,6 +82,49 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
columns.push(line_column);
|
||||
}
|
||||
|
||||
// Include dimension column for categorical color mode
|
||||
if (dimension && !columns.includes(dimension)) {
|
||||
columns.push(dimension);
|
||||
}
|
||||
|
||||
// Add metric if line_width is a metric type
|
||||
const isMetric = isMetricValue(line_width);
|
||||
const rawWidthValue =
|
||||
typeof line_width === 'string'
|
||||
? line_width
|
||||
: typeof line_width === 'number'
|
||||
? undefined
|
||||
: line_width?.value;
|
||||
const widthMetric: QueryFormMetric | null =
|
||||
isMetric &&
|
||||
rawWidthValue !== undefined &&
|
||||
typeof rawWidthValue !== 'number'
|
||||
? (rawWidthValue as QueryFormMetric)
|
||||
: null;
|
||||
|
||||
// ensure metric is not added to metric array twice
|
||||
const existingLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (widthMetric && !existingLabels.has(getMetricLabel(widthMetric))) {
|
||||
metrics = [...metrics, widthMetric];
|
||||
}
|
||||
|
||||
// ensure line_column is in groupby when aggregating by width metric
|
||||
if (widthMetric && !groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
|
||||
if (breakpoint_metric) {
|
||||
const breakpointLabel = getMetricLabel(breakpoint_metric);
|
||||
const currentLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (!currentLabels.has(breakpointLabel)) {
|
||||
metrics = [...metrics, breakpoint_metric];
|
||||
}
|
||||
// ensure line_column is in groupby when aggregating
|
||||
if (!groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
}
|
||||
|
||||
jsColumns.forEach(col => {
|
||||
if (!columns.includes(col) && !groupby.includes(col)) {
|
||||
columns.push(col);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type {
|
||||
ControlPanelSectionConfig,
|
||||
ControlSetRow,
|
||||
ControlSetItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
test('controlPanel should have Path Size section', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
expect(pathSizeSection).toBeDefined();
|
||||
expect(pathSizeSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should include pathLineWidthFixedOrMetric control', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const control = pathSizeSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width',
|
||||
) as any;
|
||||
|
||||
expect(control).toBeDefined();
|
||||
expect(control.config.type).toBe('FixedOrMetricControl');
|
||||
expect(control.config.default).toEqual({ type: 'fix', value: 1 });
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_unit control with pixels as default', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthRow = pathSizeSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthControl = lineWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthControl).toBeDefined();
|
||||
expect(lineWidthControl?.config?.default).toBe('pixels');
|
||||
});
|
||||
|
||||
test('controlPanel should include min_width control with default of 1', () => {
|
||||
const minWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const minWidthRow = minWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
),
|
||||
);
|
||||
|
||||
const minWidthControl = minWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
) as any;
|
||||
|
||||
expect(minWidthControl).toBeDefined();
|
||||
expect(minWidthControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should include max_width control with default of 20', () => {
|
||||
const maxWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const maxWidthRow = maxWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
),
|
||||
);
|
||||
|
||||
const maxWidthControl = maxWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
) as any;
|
||||
|
||||
expect(maxWidthControl).toBeDefined();
|
||||
expect(maxWidthControl?.config?.default).toBe(20);
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_multiplier control with default of 1', () => {
|
||||
const lineWidthMultiplierSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthMultiplierRow =
|
||||
lineWidthMultiplierSection?.controlSetRows.find((row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthMultiplierControl = lineWidthMultiplierRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthMultiplierControl).toBeDefined();
|
||||
expect(lineWidthMultiplierControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
expect(pathColorSection).toBeDefined();
|
||||
expect(pathColorSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section with color scheme controls', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const controlNames = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.filter(
|
||||
(control: ControlSetItem) =>
|
||||
control && typeof control === 'object' && 'name' in control,
|
||||
)
|
||||
.map((control: any) => control.name);
|
||||
|
||||
expect(controlNames).toContain('color_scheme_type');
|
||||
expect(controlNames).toContain('color_picker');
|
||||
expect(controlNames).toContain('dimension');
|
||||
expect(controlNames).toContain('color_scheme');
|
||||
expect(controlNames).toContain('breakpoint_metric');
|
||||
expect(controlNames).toContain('default_breakpoint_color');
|
||||
expect(controlNames).toContain('color_breakpoints');
|
||||
});
|
||||
|
||||
test('color_scheme_type should default to fixed_color', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const schemeTypeControl = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'color_scheme_type',
|
||||
) as any;
|
||||
|
||||
expect(schemeTypeControl).toBeDefined();
|
||||
expect(schemeTypeControl?.config?.default).toBe('fixed_color');
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
jsTooltip,
|
||||
jsOnclickHref,
|
||||
viewport,
|
||||
lineWidth,
|
||||
lineType,
|
||||
reverseLongLat,
|
||||
mapboxStyle,
|
||||
@@ -34,8 +33,12 @@ import {
|
||||
mapProvider,
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
pathLineWidthFixedOrMetric,
|
||||
generateDeckGLColorSchemeControls,
|
||||
} from '../../utilities/Shared_DeckGL';
|
||||
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
@@ -71,25 +74,83 @@ const config: ControlPanelConfig = {
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[viewport],
|
||||
['color_picker'],
|
||||
[lineWidth],
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Size'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[pathLineWidthFixedOrMetric],
|
||||
[
|
||||
{
|
||||
name: 'line_width_unit',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Line width unit'),
|
||||
default: 'meters',
|
||||
default: 'pixels',
|
||||
choices: [
|
||||
['meters', t('meters')],
|
||||
['pixels', t('pixels')],
|
||||
],
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
[
|
||||
{
|
||||
name: 'min_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Minimum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Minimum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'max_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Maximum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 20,
|
||||
description: t(
|
||||
'Maximum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'line_width_multiplier',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Width scale multiplier'),
|
||||
renderTrigger: true,
|
||||
isFloat: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Scale factor applied to metric-driven line widths',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Color'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
...generateDeckGLColorSchemeControls({
|
||||
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, DatasourceType } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
interface PathFeature {
|
||||
path: [number, number][];
|
||||
width?: number;
|
||||
metric?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const samplePath1 = JSON.stringify([
|
||||
[-122.4, 37.8],
|
||||
[-122.3, 37.9],
|
||||
]);
|
||||
const samplePath2 = JSON.stringify([
|
||||
[-122.5, 37.7],
|
||||
[-122.4, 37.8],
|
||||
]);
|
||||
const samplePath3 = JSON.stringify([
|
||||
[-122.6, 37.6],
|
||||
[-122.5, 37.7],
|
||||
]);
|
||||
|
||||
const mockChartProps: Partial<ChartProps> = {
|
||||
rawFormData: {
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
viewport: {},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
path_json: samplePath1,
|
||||
'AVG(weight)': 100,
|
||||
'SUM(distance)': 500,
|
||||
route_type: 'express',
|
||||
},
|
||||
{
|
||||
path_json: samplePath2,
|
||||
'AVG(weight)': 200,
|
||||
'SUM(distance)': 1000,
|
||||
route_type: 'local',
|
||||
},
|
||||
{
|
||||
path_json: samplePath3,
|
||||
'AVG(weight)': 50,
|
||||
'SUM(distance)': 250,
|
||||
route_type: 'express',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
datasource: {
|
||||
type: DatasourceType.Table,
|
||||
id: 1,
|
||||
name: 'test_datasource',
|
||||
columns: [],
|
||||
metrics: [],
|
||||
},
|
||||
height: 400,
|
||||
width: 600,
|
||||
hooks: {},
|
||||
filterState: {},
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('Path transformProps should parse JSON paths correctly', () => {
|
||||
const result = transformProps(mockChartProps as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.path).toBeDefined();
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
expect(f.path.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should handle empty records', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [{ data: [] }],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle missing line_column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_column: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle invalid JSON path data', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ path_json: 'not valid json' }, { path_json: '12345' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(2);
|
||||
// Should not throw, paths should be empty arrays
|
||||
features.forEach(f => {
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width value when line_width type is "fix"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width with string value', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should not set width when line_width is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use metric value for width when line_width type is "metric"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.width).toBe(50);
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric from breakpoint_metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should fall back to base metric when breakpoint_metric is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'AVG(weight)',
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should include both breakpoint_metric and width metrics if they are different', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(result.payload.data.metricLabels).toEqual([
|
||||
'AVG(weight)',
|
||||
'SUM(distance)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Path transformProps should not include both breakpoint_metric and width metrics if they are the same', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'SUM(distance)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual(['SUM(distance)']);
|
||||
});
|
||||
|
||||
test('Path transformProps should set cat_color from dimension column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
dimension: 'route_type',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.cat_color).toBe('express');
|
||||
expect(features[1]?.cat_color).toBe('local');
|
||||
expect(features[2]?.cat_color).toBe('express');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels when breakpoint_metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('AVG(weight)');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels from base metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('SUM(distance)');
|
||||
});
|
||||
|
||||
test('Path transformProps should have empty metric labels when no metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: undefined,
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual([]);
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, DTTM_ALIAS } from '@superset-ui/core';
|
||||
import { ChartProps, DTTM_ALIAS, getMetricLabel } from '@superset-ui/core';
|
||||
import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils';
|
||||
import {
|
||||
createBaseTransformResult,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
addPropertiesToFeature,
|
||||
} from '../transformUtils';
|
||||
import { DeckPathFormData } from './buildQuery';
|
||||
import { isFixedValue, getFixedValue } from '../utils/metricUtils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -48,6 +49,8 @@ interface PathFeature {
|
||||
path: [number, number][];
|
||||
metric?: number;
|
||||
timestamp?: unknown;
|
||||
width?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -91,6 +94,9 @@ function processPathData(
|
||||
reverseLongLat: boolean = false,
|
||||
metricLabel?: string,
|
||||
jsColumns?: string[],
|
||||
widthMetricLabel?: string,
|
||||
fixedWidthValue?: number | string | null,
|
||||
categoryColumn?: string,
|
||||
): PathFeature[] {
|
||||
if (!records.length || !lineColumn) {
|
||||
return [];
|
||||
@@ -103,6 +109,8 @@ function processPathData(
|
||||
'timestamp',
|
||||
DTTM_ALIAS,
|
||||
metricLabel,
|
||||
widthMetricLabel,
|
||||
categoryColumn,
|
||||
...(jsColumns || []),
|
||||
].filter(Boolean) as string[],
|
||||
);
|
||||
@@ -130,6 +138,24 @@ function processPathData(
|
||||
feature.metric = metricValue;
|
||||
}
|
||||
}
|
||||
// Set width from metric or fixed value
|
||||
if (fixedWidthValue != null) {
|
||||
// Use fixed width
|
||||
const parsedFixedWidth = parseMetricValue(fixedWidthValue);
|
||||
if (parsedFixedWidth !== undefined) {
|
||||
feature.width = parsedFixedWidth;
|
||||
}
|
||||
} else if (widthMetricLabel && record[widthMetricLabel] != null) {
|
||||
// Use metric value for width
|
||||
const widthValue = parseMetricValue(record[widthMetricLabel]);
|
||||
if (widthValue !== undefined) {
|
||||
feature.width = widthValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryColumn && record[categoryColumn] != null) {
|
||||
feature.cat_color = String(record[categoryColumn]);
|
||||
}
|
||||
|
||||
feature = addJsColumnsToExtraProps(feature, record, jsColumns);
|
||||
feature = addPropertiesToFeature(feature, record, excludeKeys);
|
||||
@@ -143,11 +169,37 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
line_column,
|
||||
line_type = 'json',
|
||||
metric,
|
||||
line_width,
|
||||
dimension,
|
||||
reverse_long_lat = false,
|
||||
js_columns,
|
||||
breakpoint_metric,
|
||||
} = formData as DeckPathTransformPropsFormData;
|
||||
|
||||
const metricLabel = getMetricLabelFromFormData(metric);
|
||||
// Check so legacy values still work
|
||||
const fixedWidthValue =
|
||||
typeof line_width === 'number'
|
||||
? line_width
|
||||
: isFixedValue(line_width)
|
||||
? getFixedValue(line_width)
|
||||
: undefined;
|
||||
|
||||
const widthMetricLabel = getMetricLabelFromFormData(line_width);
|
||||
|
||||
const breakpointMetricLabel = breakpoint_metric
|
||||
? getMetricLabel(breakpoint_metric)
|
||||
: undefined;
|
||||
const baseMetricLabel = getMetricLabelFromFormData(metric);
|
||||
const metricLabel = breakpointMetricLabel || baseMetricLabel;
|
||||
|
||||
// ensure all metric labels are included
|
||||
const metricLabels = [
|
||||
...(metricLabel ? [metricLabel] : []),
|
||||
...(widthMetricLabel && widthMetricLabel !== metricLabel
|
||||
? [widthMetricLabel]
|
||||
: []),
|
||||
];
|
||||
|
||||
const records = getRecordsFromQuery(chartProps.queriesData);
|
||||
const features = processPathData(
|
||||
records,
|
||||
@@ -156,11 +208,10 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
reverse_long_lat,
|
||||
metricLabel,
|
||||
js_columns,
|
||||
widthMetricLabel,
|
||||
fixedWidthValue,
|
||||
dimension,
|
||||
).reverse();
|
||||
|
||||
return createBaseTransformResult(
|
||||
chartProps,
|
||||
features,
|
||||
metricLabel ? [metricLabel] : [],
|
||||
);
|
||||
return createBaseTransformResult(chartProps, features, metricLabels);
|
||||
}
|
||||
|
||||
@@ -285,6 +285,22 @@ export const lineWidth = {
|
||||
},
|
||||
};
|
||||
|
||||
// created new const so as not to break lineWidth usages in other charts
|
||||
export const pathLineWidthFixedOrMetric = {
|
||||
name: 'line_width',
|
||||
config: {
|
||||
type: 'FixedOrMetricControl', // using existing type
|
||||
label: t('Line width'),
|
||||
default: { type: 'fix', value: 1 }, // kept same default as before
|
||||
description: t(
|
||||
'The width of the lines as either a fixed value or variable width based on a metric.',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => ({
|
||||
datasource: state.datasource,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const fillColorPicker: CustomControlItem = {
|
||||
name: 'fill_color_picker',
|
||||
config: {
|
||||
@@ -673,6 +689,24 @@ export const deckGLColorBreakpointsSelect: CustomControlItem = {
|
||||
},
|
||||
};
|
||||
|
||||
export const deckGLBreakpointMetric: CustomControlItem = {
|
||||
name: 'breakpoint_metric',
|
||||
config: {
|
||||
...sharedControls.metric,
|
||||
label: t('Breakpoint Metric'),
|
||||
default: null,
|
||||
validators: [],
|
||||
description: t(
|
||||
'Select the metric used to determine which color breakpoint range each path falls into.',
|
||||
),
|
||||
// mapStateToProps: (state: ControlPanelState) => ({
|
||||
// datasource: state.datasource,
|
||||
// }),
|
||||
visibility: ({ controls }: { controls: any }) =>
|
||||
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
|
||||
},
|
||||
};
|
||||
|
||||
export const breakpointsDefaultColor: CustomControlItem = {
|
||||
name: 'default_breakpoint_color',
|
||||
config: {
|
||||
@@ -725,6 +759,7 @@ export const generateDeckGLColorSchemeControls = ({
|
||||
[deckGLFixedColor],
|
||||
disableCategoricalColumn ? [] : [deckGLCategoricalColor],
|
||||
[deckGLCategoricalColorSchemeSelect],
|
||||
[deckGLBreakpointMetric],
|
||||
[breakpointsDefaultColor],
|
||||
[deckGLColorBreakpointsSelect],
|
||||
];
|
||||
|
||||
@@ -152,3 +152,33 @@ export async function selectOption(option: string, selectName?: string) {
|
||||
);
|
||||
await userEvent.click(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a compact pill filter (new UI that replaced comboboxes).
|
||||
* Clicks the pill button matching the label, then clicks the option in the panel.
|
||||
*/
|
||||
export async function selectPillOption(option: string, pillLabel?: string) {
|
||||
let pill: HTMLElement;
|
||||
if (pillLabel) {
|
||||
// Find the pill whose text content includes the label
|
||||
pill = await waitFor(() => {
|
||||
const pills = screen.getAllByTestId('compact-filter-pill');
|
||||
const match = pills.find(p => p.textContent?.includes(pillLabel));
|
||||
if (!match)
|
||||
throw new Error(`Could not find pill with label "${pillLabel}"`);
|
||||
return match;
|
||||
});
|
||||
} else {
|
||||
pill = await screen.findByTestId('compact-filter-pill');
|
||||
}
|
||||
await userEvent.click(pill);
|
||||
// Wait for the option list to appear and click the item
|
||||
const item = await waitFor(() => {
|
||||
const listbox = document.querySelector('[role="listbox"]');
|
||||
if (!listbox) throw new Error('No listbox found');
|
||||
const opt = within(listbox as HTMLElement).getByText(option);
|
||||
if (!opt) throw new Error(`Option "${option}" not found`);
|
||||
return opt;
|
||||
});
|
||||
await userEvent.click(item);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ const Title = styled.h4`
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
const StyledTabs = styled(Tabs)`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * -8}px;
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
|
||||
const ssql = sql || '';
|
||||
let lines = ssql.split('\n');
|
||||
@@ -94,7 +101,7 @@ function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
<StyledTabs
|
||||
defaultActiveKey="executed"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 config from './controlPanel';
|
||||
|
||||
type ControlConfig = {
|
||||
label?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
type ControlItem = {
|
||||
config: ControlConfig;
|
||||
} | null;
|
||||
|
||||
function collectFunctionProps(cfg: typeof config) {
|
||||
const fns: Array<() => unknown> = [];
|
||||
cfg.controlPanelSections.forEach(section => {
|
||||
section?.controlSetRows.forEach(row => {
|
||||
(row as ControlItem[]).forEach(item => {
|
||||
if (item && typeof item === 'object' && 'config' in item) {
|
||||
const { label, description } = item.config;
|
||||
if (typeof label === 'function') fns.push(label as () => unknown);
|
||||
if (typeof description === 'function')
|
||||
fns.push(description as () => unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return fns;
|
||||
}
|
||||
|
||||
test('DynamicGroupBy controlPanel label and description functions return strings', () => {
|
||||
const fns = collectFunctionProps(config);
|
||||
expect(fns.length).toBeGreaterThan(0);
|
||||
fns.forEach(fn => {
|
||||
expect(typeof fn()).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -49,12 +49,12 @@ const config: ControlPanelConfig = {
|
||||
name: 'canSelectMultiple',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Can select multiple values'),
|
||||
label: () => t('Can select multiple values'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
resetConfig: true,
|
||||
affectsDataMask: true,
|
||||
description: t('Allow users to select multiple values'),
|
||||
description: () => t('Allow users to select multiple values'),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -63,12 +63,13 @@ const config: ControlPanelConfig = {
|
||||
name: 'enableEmptyFilter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Chart customization value is required'),
|
||||
label: () => t('Chart customization value is required'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'User must select a value before applying the chart customization',
|
||||
),
|
||||
description: () =>
|
||||
t(
|
||||
'User must select a value before applying the chart customization',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 config from './controlPanel';
|
||||
|
||||
type ControlConfig = {
|
||||
label?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
type ControlItem = {
|
||||
config: ControlConfig;
|
||||
} | null;
|
||||
|
||||
function collectFunctionProps(cfg: typeof config) {
|
||||
const fns: Array<() => unknown> = [];
|
||||
cfg.controlPanelSections.forEach(section => {
|
||||
section?.controlSetRows.forEach(row => {
|
||||
(row as ControlItem[]).forEach(item => {
|
||||
if (item && typeof item === 'object' && 'config' in item) {
|
||||
const { label, description } = item.config;
|
||||
if (typeof label === 'function') fns.push(label as () => unknown);
|
||||
if (typeof description === 'function')
|
||||
fns.push(description as () => unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return fns;
|
||||
}
|
||||
|
||||
test('TimeColumn controlPanel label and description functions return strings', () => {
|
||||
const fns = collectFunctionProps(config);
|
||||
expect(fns.length).toBeGreaterThan(0);
|
||||
fns.forEach(fn => {
|
||||
expect(typeof fn()).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -30,12 +30,11 @@ const config: ControlPanelConfig = {
|
||||
name: 'enableEmptyFilter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Filter value is required'),
|
||||
label: () => t('Filter value is required'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'User must select a value before applying the filter',
|
||||
),
|
||||
description: () =>
|
||||
t('User must select a value before applying the filter'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 config from './controlPanel';
|
||||
|
||||
type ControlConfig = {
|
||||
label?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
type ControlItem = {
|
||||
config: ControlConfig;
|
||||
} | null;
|
||||
|
||||
function collectFunctionProps(cfg: typeof config) {
|
||||
const fns: Array<() => unknown> = [];
|
||||
cfg.controlPanelSections.forEach(section => {
|
||||
section?.controlSetRows.forEach(row => {
|
||||
(row as ControlItem[]).forEach(item => {
|
||||
if (item && typeof item === 'object' && 'config' in item) {
|
||||
const { label, description } = item.config;
|
||||
if (typeof label === 'function') fns.push(label as () => unknown);
|
||||
if (typeof description === 'function')
|
||||
fns.push(description as () => unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return fns;
|
||||
}
|
||||
|
||||
test('TimeGrain controlPanel label and description functions return strings', () => {
|
||||
const fns = collectFunctionProps(config);
|
||||
expect(fns.length).toBeGreaterThan(0);
|
||||
fns.forEach(fn => {
|
||||
expect(typeof fn()).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -30,12 +30,11 @@ const config: ControlPanelConfig = {
|
||||
name: 'enableEmptyFilter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Customization value is required'),
|
||||
label: () => t('Customization value is required'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'User must select a value before applying the customization',
|
||||
),
|
||||
description: () =>
|
||||
t('User must select a value before applying the customization'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
getQuerySettings,
|
||||
getChartDataUri,
|
||||
} from 'src/explore/exploreUtils';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
addDangerToast,
|
||||
addWarningToast,
|
||||
} from 'src/components/MessageToasts/actions';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
|
||||
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
|
||||
@@ -813,6 +816,14 @@ export function exploreJSON(
|
||||
}),
|
||||
),
|
||||
);
|
||||
(queriesResponse as QueryData[]).forEach(response => {
|
||||
const { warning } = response as QueryData & {
|
||||
warning?: string | null;
|
||||
};
|
||||
if (warning) {
|
||||
dispatch(addWarningToast(warning, { noDuplicate: true }));
|
||||
}
|
||||
});
|
||||
return dispatch(
|
||||
chartUpdateSucceeded(queriesResponse as QueryData[], key as number),
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
AnnotationSourceType,
|
||||
AnnotationStyle,
|
||||
} from '@superset-ui/core';
|
||||
import * as toastActions from 'src/components/MessageToasts/actions';
|
||||
import { LOG_EVENT } from 'src/logger/actions';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import * as actions from 'src/components/Chart/chartAction';
|
||||
@@ -412,6 +413,56 @@ describe('chart actions', () => {
|
||||
);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('dispatches addWarningToast when a query response includes a warning', async () => {
|
||||
const warningMessage =
|
||||
'Results truncated to 1,000 rows due to memory constraints.';
|
||||
fetchMock.removeRoute(MOCK_URL);
|
||||
fetchMock.post(
|
||||
`glob:*${MOCK_URL}*`,
|
||||
{ result: [{ warning: warningMessage }] },
|
||||
{ name: MOCK_URL },
|
||||
);
|
||||
const addWarningToastSpy = jest.spyOn(toastActions, 'addWarningToast');
|
||||
|
||||
const actionThunk = actions.postChartFormData(
|
||||
{ viz_type: 'my_viz' } as QueryFormData,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
await actionThunk(
|
||||
dispatch as unknown as actions.ChartThunkDispatch,
|
||||
mockGetState as unknown as () => actions.RootState,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(addWarningToastSpy).toHaveBeenCalledWith(warningMessage, {
|
||||
noDuplicate: true,
|
||||
});
|
||||
addWarningToastSpy.mockRestore();
|
||||
fetchMock.removeRoute(MOCK_URL);
|
||||
setupDefaultFetchMock();
|
||||
});
|
||||
|
||||
test('does not dispatch addWarningToast when no query response has a warning', async () => {
|
||||
const addWarningToastSpy = jest.spyOn(toastActions, 'addWarningToast');
|
||||
|
||||
const actionThunk = actions.postChartFormData(
|
||||
{ viz_type: 'my_viz' } as QueryFormData,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
await actionThunk(
|
||||
dispatch as unknown as actions.ChartThunkDispatch,
|
||||
mockGetState as unknown as () => actions.RootState,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(addWarningToastSpy).not.toHaveBeenCalled();
|
||||
addWarningToastSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -16,20 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { FormLabel, Select } from '@superset-ui/core/components';
|
||||
import { SELECT_WIDTH } from './utils';
|
||||
import type { SelectOption } from './types';
|
||||
import { CardSortSelectOption, SortColumn } from './types';
|
||||
|
||||
const SortContainer = styled.div`
|
||||
display: inline-flex;
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
width: ${SELECT_WIDTH}px;
|
||||
`;
|
||||
import CompactFilterTrigger from './Filters/CompactFilterTrigger';
|
||||
import CompactSelectPanel from './Filters/CompactSelectPanel';
|
||||
import type { FilterHandler } from './Filters/types';
|
||||
|
||||
interface CardViewSelectSortProps {
|
||||
onChange: (value: SortColumn[]) => void;
|
||||
@@ -42,6 +35,8 @@ export const CardSortSelect = ({
|
||||
onChange,
|
||||
options,
|
||||
}: CardViewSelectSortProps) => {
|
||||
const panelRef = useRef<FilterHandler>(null);
|
||||
|
||||
const defaultSort =
|
||||
(initialSort &&
|
||||
options.find(
|
||||
@@ -50,44 +45,57 @@ export const CardSortSelect = ({
|
||||
)) ||
|
||||
options[0];
|
||||
|
||||
const [value, setValue] = useState({
|
||||
const [currentValue, setCurrentValue] = useState<SelectOption>({
|
||||
label: defaultSort.label,
|
||||
value: defaultSort.value,
|
||||
});
|
||||
|
||||
const formattedOptions = useMemo(
|
||||
() => options.map(option => ({ label: option.label, value: option.value })),
|
||||
[options],
|
||||
);
|
||||
const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
|
||||
|
||||
const handleOnChange = (selected: { label: string; value: string }) => {
|
||||
setValue(selected);
|
||||
const originalOption = options.find(
|
||||
({ value }) => value === selected.value,
|
||||
);
|
||||
if (originalOption) {
|
||||
const sortBy = [
|
||||
{
|
||||
id: originalOption.id,
|
||||
desc: originalOption.desc,
|
||||
},
|
||||
];
|
||||
onChange(sortBy);
|
||||
const isNonDefault = currentValue.value !== options[0]?.value;
|
||||
|
||||
const handleSelect = (option: SelectOption | undefined) => {
|
||||
if (!option) return;
|
||||
const original = options.find(o => o.value === option.value);
|
||||
if (original) {
|
||||
setCurrentValue({ label: original.label, value: original.value });
|
||||
onChange([{ id: original.id, desc: original.desc }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
const first = options[0];
|
||||
if (first) {
|
||||
setCurrentValue({ label: first.label, value: first.value });
|
||||
onChange([{ id: first.id, desc: first.desc }]);
|
||||
}
|
||||
};
|
||||
|
||||
// Show the active sort value in the label so users can see the current sort
|
||||
// without hovering — matches the previous inline-select UX.
|
||||
const pillLabel = isNonDefault
|
||||
? `${t('Sort')}: ${String(currentValue.label)}`
|
||||
: t('Sort');
|
||||
|
||||
return (
|
||||
<SortContainer>
|
||||
<Select
|
||||
ariaLabel={t('Sort')}
|
||||
header={<FormLabel>{t('Sort')}</FormLabel>}
|
||||
labelInValue
|
||||
onChange={handleOnChange}
|
||||
options={formattedOptions}
|
||||
showSearch
|
||||
value={value}
|
||||
data-test="card-sort-select"
|
||||
/>
|
||||
</SortContainer>
|
||||
<span data-test="card-sort-select">
|
||||
<CompactFilterTrigger
|
||||
label={pillLabel}
|
||||
hasValue={isNonDefault}
|
||||
onClear={handleClear}
|
||||
tooltipTitle={isNonDefault ? String(currentValue.label) : undefined}
|
||||
>
|
||||
{({ isOpen, onClose }) => (
|
||||
<CompactSelectPanel
|
||||
ref={panelRef}
|
||||
selects={selectOptions}
|
||||
value={currentValue}
|
||||
onSelect={handleSelect}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CompactFilterTrigger from './CompactFilterTrigger';
|
||||
|
||||
// Base props without children — pass children as JSX to avoid no-children-prop lint rule.
|
||||
const baseProps = {
|
||||
label: 'Owner',
|
||||
hasValue: false,
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
const defaultChildren = jest.fn(() => (
|
||||
<div data-testid="filter-content">Filter content</div>
|
||||
));
|
||||
|
||||
function renderTrigger(
|
||||
props: Partial<
|
||||
typeof baseProps & {
|
||||
hasValue: boolean;
|
||||
tooltipTitle?: string;
|
||||
popupType?: 'listbox' | 'dialog';
|
||||
}
|
||||
> = {},
|
||||
children = defaultChildren,
|
||||
) {
|
||||
return render(
|
||||
<CompactFilterTrigger {...baseProps} {...props}>
|
||||
{children}
|
||||
</CompactFilterTrigger>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders the label', () => {
|
||||
renderTrigger();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders as inactive pill with down chevron when hasValue is false', () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
// No clear button when inactive
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders active state with clear button when hasValue is true', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clear button has descriptive aria-label matching the filter name', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
const clearBtn = screen.getByTestId('compact-filter-clear');
|
||||
expect(clearBtn).toHaveAttribute('aria-label', 'Clear Owner filter');
|
||||
});
|
||||
|
||||
test('clear button is a separate element from the pill button', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
const clearBtn = screen.getByTestId('compact-filter-clear');
|
||||
// Buttons must not be nested
|
||||
expect(pill).not.toContainElement(clearBtn);
|
||||
expect(clearBtn).not.toContainElement(pill);
|
||||
});
|
||||
|
||||
test('toggles aria-expanded when pill is clicked', async () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'false');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('calls onClear when clear button is clicked', async () => {
|
||||
const onClear = jest.fn();
|
||||
renderTrigger({ hasValue: true, onClear } as any);
|
||||
const clearBtn = screen.getByRole('button', { name: /clear owner filter/i });
|
||||
await userEvent.click(clearBtn);
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not render tooltip wrapper when tooltipTitle is absent', () => {
|
||||
const { container } = renderTrigger();
|
||||
expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows active state indicators when hasValue and tooltipTitle are set', () => {
|
||||
renderTrigger({ hasValue: true, tooltipTitle: 'Some Owner' });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('calls children render prop with isOpen and onClose', async () => {
|
||||
const children = jest.fn(() => <div data-testid="panel-content">panel</div>);
|
||||
renderTrigger({}, children);
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
await userEvent.click(pill);
|
||||
expect(children).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('sets aria-haspopup to listbox by default', () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
|
||||
});
|
||||
|
||||
test('sets aria-haspopup to dialog when popupType is dialog', () => {
|
||||
renderTrigger({ popupType: 'dialog' });
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
});
|
||||
|
||||
test('closing dropdown resets aria-expanded to false', async () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'true');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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 { useEffect, useState, type ReactNode, type MouseEvent } from 'react';
|
||||
import { useTheme, styled, css } from '@apache-superset/core/theme';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
export type FilterPanelRenderProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
interface CompactFilterTriggerProps {
|
||||
label: ReactNode;
|
||||
hasValue: boolean;
|
||||
onClear: () => void;
|
||||
/** Render prop: receives { isOpen, onClose } and returns the panel content. */
|
||||
children: (props: FilterPanelRenderProps) => ReactNode;
|
||||
/** Shown as a hover tooltip when a value is selected (e.g. the selected label). */
|
||||
tooltipTitle?: string;
|
||||
/** ARIA popup role for the trigger button. Use 'listbox' for option panels,
|
||||
* 'dialog' for form panels (date range, numerical range). */
|
||||
popupType?: 'listbox' | 'dialog';
|
||||
}
|
||||
|
||||
const TriggerWrapper = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const FilterPill = styled.button<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
height: ${theme.controlHeight}px;
|
||||
padding: 0 ${theme.sizeUnit * 3}px;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
|
||||
background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
|
||||
color: ${$active ? theme.colorPrimary : theme.colorText};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
font-weight: ${$active ? 600 : 400};
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.colorPrimary};
|
||||
background: ${$active ? theme.colorPrimaryBgHover : theme.colorFillAlter};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ActiveDot = styled.span`
|
||||
${({ theme }) => css`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: ${theme.colorPrimary};
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
// Meets WCAG 2.5.5 target size (24×24 minimum) with explicit dimensions.
|
||||
const ClearButton = styled.button`
|
||||
${({ theme }) => css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin-left: ${theme.sizeUnit / 2}px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${theme.colorPrimary};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorPrimaryBg};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function CompactFilterTrigger({
|
||||
label,
|
||||
hasValue,
|
||||
onClear,
|
||||
children,
|
||||
tooltipTitle,
|
||||
popupType = 'listbox',
|
||||
}: CompactFilterTriggerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
// Close dropdown on window resize — AntD Dropdown doesn't reposition
|
||||
// itself on resize so the panel ends up detached from the pill.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleResize = () => setOpen(false);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [open]);
|
||||
|
||||
const handleClear = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const clearAriaLabel =
|
||||
typeof label === 'string' ? `Clear ${label} filter` : 'Clear filter';
|
||||
|
||||
return (
|
||||
<TriggerWrapper>
|
||||
<Dropdown
|
||||
open={open}
|
||||
onOpenChange={visible => {
|
||||
setOpen(visible);
|
||||
if (!visible) setTooltipOpen(false);
|
||||
}}
|
||||
trigger={['click']}
|
||||
popupRender={() =>
|
||||
children({ isOpen: open, onClose: () => setOpen(false) })
|
||||
}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Tooltip
|
||||
title={tooltipTitle}
|
||||
open={!!tooltipTitle && !open && tooltipOpen}
|
||||
onOpenChange={visible =>
|
||||
setTooltipOpen(visible && !!tooltipTitle && !open)
|
||||
}
|
||||
mouseEnterDelay={0.5}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<FilterPill
|
||||
$active={hasValue}
|
||||
type="button"
|
||||
data-test="compact-filter-pill"
|
||||
aria-haspopup={popupType}
|
||||
aria-expanded={open}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
>
|
||||
{hasValue && <ActiveDot />}
|
||||
<span>{label}</span>
|
||||
<Icons.DownOutlined
|
||||
iconSize="xs"
|
||||
iconColor={
|
||||
hasValue ? theme.colorPrimary : theme.colorTextSecondary
|
||||
}
|
||||
/>
|
||||
</FilterPill>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
{hasValue && (
|
||||
<ClearButton
|
||||
type="button"
|
||||
data-test="compact-filter-clear"
|
||||
aria-label={clearAriaLabel}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Icons.CloseOutlined iconSize="s" iconColor={theme.colorPrimary} />
|
||||
</ClearButton>
|
||||
)}
|
||||
</TriggerWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 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 { createRef, act } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CompactSelectPanel from './CompactSelectPanel';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const SMALL_SELECTS = [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
{ label: 'Charlie', value: 3 },
|
||||
];
|
||||
|
||||
const LARGE_SELECTS = [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
{ label: 'Charlie', value: 3 },
|
||||
{ label: 'David', value: 4 },
|
||||
{ label: 'Eve', value: 5 },
|
||||
{ label: 'Frank', value: 6 },
|
||||
{ label: 'Grace', value: 7 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders options from selects prop', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides search input when selects.length is 6 or fewer', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows search input when selects.length exceeds 6', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={LARGE_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows search input when fetchSelects is provided', () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('filters static options by search term', async () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={LARGE_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onSelect with normalized option when an option is clicked', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
|
||||
});
|
||||
|
||||
test('calls onSelect with undefined when same option is clicked twice (deselect)', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('shows checkmark icon on selected option', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const aliceOption = screen
|
||||
.getByText('Alice')
|
||||
.closest('[role="option"]') as HTMLElement;
|
||||
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('unselected options have aria-selected false', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const bobOption = screen
|
||||
.getByText('Bob')
|
||||
.closest('[role="option"]') as HTMLElement;
|
||||
expect(bobOption).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
test('calls onClose after a selection is made', async () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('clearFilter via ref resets selection and calls onSelect(undefined, true)', () => {
|
||||
const onSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
const { rerender } = render(
|
||||
<CompactSelectPanel
|
||||
ref={ref}
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.clearFilter();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, true);
|
||||
// Component is fully controlled — visual deselection follows when the
|
||||
// parent passes value={undefined} after receiving the onSelect callback.
|
||||
rerender(
|
||||
<CompactSelectPanel
|
||||
ref={ref}
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows Loading text when loading prop is true', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
loading
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows No results when displayOptions is empty', () => {
|
||||
render(
|
||||
<CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders options list with listbox role and accessible label', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(listbox).toBeInTheDocument();
|
||||
expect(listbox).toHaveAttribute('aria-label', 'Filter options');
|
||||
});
|
||||
|
||||
test('option items have option role', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('fetches and displays remote options via fetchSelects on mount', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [{ label: 'Remote User', value: 99 }],
|
||||
totalCount: 1,
|
||||
});
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remote User')).toBeInTheDocument();
|
||||
});
|
||||
expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
|
||||
});
|
||||
|
||||
test('shows No results when fetchSelects returns empty data', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows No results when fetchSelects rejects', async () => {
|
||||
const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('selects option via keyboard Enter key', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
|
||||
await userEvent.type(aliceOption, '{Enter}');
|
||||
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
|
||||
});
|
||||
|
||||
test('syncs selected state when external value prop changes', () => {
|
||||
const { rerender } = render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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 {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme, styled, css } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Icons,
|
||||
Input,
|
||||
Constants,
|
||||
type InputRef,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { SelectOption, ListViewFilter as Filter } from '../types';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
// Show search box when there are more than this many static options.
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
// Page size for async select fetches — large enough to avoid most pagination
|
||||
// issues while still being a bounded request. Full infinite-load pagination
|
||||
// is a future improvement.
|
||||
const ASYNC_PAGE_SIZE = 200;
|
||||
|
||||
interface CompactSelectPanelProps {
|
||||
selects?: Filter['selects'];
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
value?: SelectOption;
|
||||
onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
|
||||
onClose?: () => void;
|
||||
isOpen?: boolean;
|
||||
/** Forwarded from the filter config's popupStyle for per-filter width overrides */
|
||||
panelStyle?: CSSProperties;
|
||||
/** External loading state from filter config */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = styled.div<{ $panelStyle?: CSSProperties }>`
|
||||
${({ theme }) => css`
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
padding: 0 0 ${theme.paddingXXS}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SearchRow = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px
|
||||
${theme.paddingXXS}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionList = styled.ul`
|
||||
${({ theme }) => css`
|
||||
margin: 0;
|
||||
padding: ${theme.paddingXXS}px 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionItem = styled.li<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${(theme.controlHeight - theme.fontSize * theme.lineHeight) / 2}px
|
||||
${theme.controlPaddingHorizontal}px;
|
||||
line-height: ${theme.lineHeight};
|
||||
cursor: pointer;
|
||||
font-size: ${theme.fontSize}px;
|
||||
color: ${theme.colorText};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
background: ${$active ? theme.colorPrimaryBg : 'transparent'};
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: ${$active
|
||||
? theme.colorPrimaryBgHover
|
||||
: theme.colorFillTertiary};
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionLabel = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
`;
|
||||
|
||||
const StatusText = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
|
||||
text-align: center;
|
||||
color: ${theme.colorTextDisabled};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
function CompactSelectPanel(
|
||||
{
|
||||
selects = [],
|
||||
fetchSelects,
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
isOpen,
|
||||
loading: externalLoading,
|
||||
panelStyle,
|
||||
}: CompactSelectPanelProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [remoteOptions, setRemoteOptions] = useState<SelectOption[]>([]);
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
|
||||
const isLoading = externalLoading || internalLoading;
|
||||
|
||||
const debouncedSetSearch = useMemo(
|
||||
() => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debouncedSetSearch.cancel();
|
||||
},
|
||||
[debouncedSetSearch],
|
||||
);
|
||||
|
||||
// Focus search input when dropdown opens; reset search when it closes
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
if (isOpen) {
|
||||
timeoutId = setTimeout(() => {
|
||||
inputRef.current?.input?.focus({ preventScroll: true });
|
||||
}, 100);
|
||||
} else {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch remote options when debounced search changes
|
||||
useEffect(() => {
|
||||
if (!fetchSelects) return;
|
||||
let cancelled = false;
|
||||
setInternalLoading(true);
|
||||
fetchSelects(debouncedSearch, 0, ASYNC_PAGE_SIZE)
|
||||
.then(result => {
|
||||
if (!cancelled) setRemoteOptions(result?.data ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRemoteOptions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setInternalLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedSearch, fetchSelects]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
onSelect(undefined, true);
|
||||
},
|
||||
}));
|
||||
|
||||
const displayOptions = (
|
||||
fetchSelects
|
||||
? remoteOptions
|
||||
: selects.filter(o => {
|
||||
const label = typeof o.label === 'string' ? o.label : String(o.value);
|
||||
return label.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
).filter(o => o != null);
|
||||
|
||||
const showSearch = !!fetchSelects || selects.length > SEARCH_THRESHOLD;
|
||||
|
||||
const handleSelect = (opt: SelectOption, displayText?: string) => {
|
||||
const isDeselect = value?.value === opt.value;
|
||||
// Normalize to a plain string label for URL serialization:
|
||||
// 1. String labels pass through unchanged.
|
||||
// 2. ReactNode labels with a `title` field use that (set by callers for
|
||||
// options like owner-select where label contains name + email JSX).
|
||||
// 3. Fall back to DOM text content, then stringified value.
|
||||
const label =
|
||||
typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: (opt.title ?? displayText ?? String(opt.value ?? ''));
|
||||
const next = isDeselect ? undefined : { label, value: opt.value };
|
||||
onSelect(next, isDeselect);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelContainer style={panelStyle}>
|
||||
{showSearch && (
|
||||
<SearchRow>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
prefix={
|
||||
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
|
||||
}
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
debouncedSetSearch(e.target.value);
|
||||
}}
|
||||
allowClear
|
||||
css={css`
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
`}
|
||||
/>
|
||||
</SearchRow>
|
||||
)}
|
||||
<OptionList role="listbox" aria-label={t('Filter options')}>
|
||||
{isLoading ? (
|
||||
<StatusText>{t('Loading...')}</StatusText>
|
||||
) : displayOptions.length === 0 ? (
|
||||
<StatusText>{t('No results')}</StatusText>
|
||||
) : (
|
||||
displayOptions.map((opt, i) => {
|
||||
const isActive = value?.value === opt.value;
|
||||
const getDisplayText = (el: HTMLElement) =>
|
||||
el.textContent?.trim() || undefined;
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === displayOptions.length - 1;
|
||||
return (
|
||||
<OptionItem
|
||||
key={opt.value}
|
||||
$active={isActive}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
onClick={e =>
|
||||
handleSelect(opt, getDisplayText(e.currentTarget))
|
||||
}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(opt, getDisplayText(e.currentTarget));
|
||||
} else if (e.key === 'ArrowDown' && !isLast) {
|
||||
e.preventDefault();
|
||||
(
|
||||
e.currentTarget.nextElementSibling as HTMLElement | null
|
||||
)?.focus();
|
||||
} else if (e.key === 'ArrowUp' && !isFirst) {
|
||||
e.preventDefault();
|
||||
(
|
||||
e.currentTarget
|
||||
.previousElementSibling as HTMLElement | null
|
||||
)?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OptionLabel>{opt.label}</OptionLabel>
|
||||
{isActive && (
|
||||
<Icons.CheckOutlined
|
||||
iconSize="s"
|
||||
iconColor={theme.colorPrimary}
|
||||
/>
|
||||
)}
|
||||
</OptionItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</OptionList>
|
||||
</PanelContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(CompactSelectPanel);
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FilterPopoverContent from './FilterPopoverContent';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders children inside the wrapper', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div data-test="inner-content">Inner content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
expect(screen.getByTestId('inner-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Apply button', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onClose when Apply button is clicked', async () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<FilterPopoverContent onClose={onClose}>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders without onClose and clicking Apply does not throw', async () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
// No onClose prop — click should not throw
|
||||
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('visually hides label elements so pills remain accessible', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<label htmlFor="input">Date range</label>
|
||||
<input id="input" />
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
const label = screen.getByText('Date range');
|
||||
// The label must be in the DOM for screen readers but visually hidden via CSS
|
||||
expect(label).toBeInTheDocument();
|
||||
const computedStyle = window.getComputedStyle(label);
|
||||
// clip / overflow hidden pattern applied; position absolute is the key indicator
|
||||
expect(computedStyle.position).toBe('absolute');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 type { ReactNode } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { Button } from '@superset-ui/core/components';
|
||||
|
||||
interface FilterPopoverContentProps {
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
|
||||
/* Visually hide the redundant label — the pill already shows it, but keep it
|
||||
accessible to screen readers so filter inputs have a named context. */
|
||||
label {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export default function FilterPopoverContent({
|
||||
children,
|
||||
onClose,
|
||||
}: FilterPopoverContentProps) {
|
||||
return (
|
||||
<Wrapper>
|
||||
{children}
|
||||
<Footer>
|
||||
<Button size="small" buttonStyle="primary" onClick={onClose}>
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
</Footer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import { createRef, act } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
selectOption,
|
||||
selectPillOption,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
@@ -79,7 +79,7 @@ test('select filter with ReactNode label uses option title when serializing sele
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('John Doe', 'Owner');
|
||||
await selectPillOption('John Doe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
@@ -120,7 +120,7 @@ test('select filter falls back to stringified value when no string label or titl
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('123', 'Something');
|
||||
await selectPillOption('123', 'Something');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
@@ -156,7 +156,7 @@ test('plain select with string label passes label through unchanged', async () =
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
await selectPillOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
@@ -197,7 +197,7 @@ test('plain select with ReactNode label uses option title when serializing selec
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Jane Roe', 'Owner');
|
||||
await selectPillOption('Jane Roe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
@@ -224,16 +224,18 @@ test('clearFilter notifies onSelect with undefined and isClear=true', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
ref.current?.clearFilter();
|
||||
act(() => {
|
||||
ref.current?.clearFilter();
|
||||
});
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('rehydrates filter pill from initialValue with plain-string label', async () => {
|
||||
// The user-visible regression: after URL/state rehydration the filter pill
|
||||
// must render the human-readable name, not the numeric user id. The fix
|
||||
// ensures the persisted label is a string; this test asserts that string
|
||||
// is what surfaces in the rendered combobox selection.
|
||||
// In the compact pill UI the rehydrated label is surfaced as the tooltip
|
||||
// (visible on hover) rather than inline text. We verify the pill is in
|
||||
// active state — the clear button is rendered — which confirms the
|
||||
// SelectOption value object was correctly rehydrated.
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
@@ -262,6 +264,6 @@ test('rehydrates filter pill from initialValue with plain-string label', async (
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,278 @@ test('search filter passes autoComplete prop correctly', () => {
|
||||
expect(input.autocomplete).toBe('new-password');
|
||||
});
|
||||
|
||||
test('renders multiple search filters with different inputName values', () => {
|
||||
test('renders a compact pill trigger for select filters', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('compact-filter-pill')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('select pill shows active state (clear button) when a value is selected', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [{ label: 'Alice', value: 1 }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owner',
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
value: { label: 'Alice', value: 1 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('select pill tooltip falls back to static selects on cold URL load (no cached label)', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate cold URL load: value has only numeric value, no label in cache
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owner',
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
value: { value: 1 } as any,
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The pill should be active (clear button visible) and the static label
|
||||
// should be resolved as the tooltip source
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(screen.getByText('Time range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range pill shows active state when value is set', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: ['2024-01-01', '2024-12-31'],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear time range filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range tooltip formats unix timestamps as human-readable dates', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
dateFilterValueType: 'unix' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Jan 1 2024 00:00:00 UTC in ms
|
||||
const start = 1704067200000;
|
||||
const end = 1735689599000;
|
||||
|
||||
const { container } = render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: [start, end],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should NOT contain raw unix timestamp numbers in the rendered output
|
||||
expect(container.textContent).not.toContain(String(start));
|
||||
expect(container.textContent).not.toContain(String(end));
|
||||
});
|
||||
|
||||
test('datetime_range tooltip leaves ISO strings as-is', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: ['2024-01-01T00:00:00.000Z', '2024-12-31T23:59:59.000Z'],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Pill is active (clear button present)
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear time range filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('numerical_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(screen.getByText('Age range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('numerical_range pill shows active state when value is set', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'age_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: [18, 65],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear age range filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only the first search filter when multiple search filters are configured', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Name',
|
||||
@@ -125,8 +396,8 @@ test('renders multiple search filters with different inputName values', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only the first search filter renders — one search box per page
|
||||
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].name).toBe('filter_name_search');
|
||||
expect(inputs[1].name).toBe('description');
|
||||
});
|
||||
|
||||
@@ -19,11 +19,14 @@
|
||||
import {
|
||||
createRef,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { extendedDayjs } from '@superset-ui/core/utils/dates';
|
||||
import { withTheme } from '@apache-superset/core/theme';
|
||||
|
||||
import type {
|
||||
@@ -34,9 +37,11 @@ import type {
|
||||
} from '../types';
|
||||
import type { FilterHandler } from './types';
|
||||
import SearchFilter from './Search';
|
||||
import SelectFilter from './Select';
|
||||
import DateRangeFilter from './DateRange';
|
||||
import NumericalRangeFilter from './NumericalRange';
|
||||
import CompactFilterTrigger from './CompactFilterTrigger';
|
||||
import CompactSelectPanel from './CompactSelectPanel';
|
||||
import FilterPopoverContent from './FilterPopoverContent';
|
||||
|
||||
interface UIFiltersProps {
|
||||
filters: Filters;
|
||||
@@ -46,7 +51,10 @@ interface UIFiltersProps {
|
||||
|
||||
function UIFilters(
|
||||
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
|
||||
ref: RefObject<{ clearFilters: () => void }>,
|
||||
ref: RefObject<{
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>,
|
||||
) {
|
||||
const filterRefs = useMemo(
|
||||
() =>
|
||||
@@ -54,20 +62,51 @@ function UIFilters(
|
||||
[filters.length],
|
||||
);
|
||||
|
||||
// Cache display labels for select filters so tooltip works after URL round-trip
|
||||
// (URL serialization strips the label, leaving only the value).
|
||||
const [tooltipLabels, setTooltipLabels] = useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const clearFilterAtIndex = useCallback(
|
||||
(index: number) => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
updateFilterValue(index, undefined);
|
||||
setTooltipLabels(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[updateFilterValue],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilters: () => {
|
||||
filterRefs.forEach((filter: any) => {
|
||||
filter.current?.clearFilter?.();
|
||||
filterRefs.forEach((_, index) => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
updateFilterValue(index, undefined);
|
||||
});
|
||||
setTooltipLabels({});
|
||||
},
|
||||
clearFilterById: (id: string) => {
|
||||
const index = filters.findIndex(f => f.id === id);
|
||||
if (index >= 0) {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
clearFilterAtIndex(index);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Only the first search filter renders inline; subsequent ones are skipped
|
||||
// to keep one search box per page (multi-field search pages like Users would
|
||||
// otherwise show several input boxes in the header).
|
||||
// NOTE: This means secondary search fields (e.g. Email/Username on Users,
|
||||
// Group Key on RLS) are not currently accessible via the filter bar. Those
|
||||
// pages previously relied on multiple inline inputs. This is a known UX
|
||||
// trade-off — revisit if admin workflows require additional search fields.
|
||||
let searchFilterRendered = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{filters.map(
|
||||
@@ -78,8 +117,6 @@ function UIFilters(
|
||||
key,
|
||||
id,
|
||||
input,
|
||||
optionFilterProps,
|
||||
paginate,
|
||||
selects,
|
||||
toolTipDescription,
|
||||
onFilterUpdate,
|
||||
@@ -87,44 +124,72 @@ function UIFilters(
|
||||
dateFilterValueType,
|
||||
min,
|
||||
max,
|
||||
popupStyle,
|
||||
autoComplete,
|
||||
inputName,
|
||||
popupStyle,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
const initialValue = internalFilters?.[index]?.value;
|
||||
if (input === 'select') {
|
||||
const selectValue = initialValue as SelectOption | undefined;
|
||||
// Prefer cached label (survives URL round-trips where only the value
|
||||
// is preserved). Fall back to the static selects list for cold loads.
|
||||
const cachedLabel = tooltipLabels[index];
|
||||
const staticFallback = cachedLabel
|
||||
? undefined
|
||||
: selects?.find(s => s.value === selectValue?.value)?.label;
|
||||
const tooltipTitle = !!selectValue
|
||||
? cachedLabel ||
|
||||
(typeof staticFallback === 'string'
|
||||
? staticFallback
|
||||
: undefined)
|
||||
: undefined;
|
||||
return (
|
||||
<SelectFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
fetchSelects={fetchSelects}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={id}
|
||||
onSelect={(
|
||||
option: SelectOption | undefined,
|
||||
isClear?: boolean,
|
||||
) => {
|
||||
if (onFilterUpdate) {
|
||||
// Filter change triggers both onChange AND onClear, only want to track onChange
|
||||
if (!isClear) {
|
||||
onFilterUpdate(option);
|
||||
}
|
||||
}
|
||||
|
||||
updateFilterValue(index, option);
|
||||
}}
|
||||
optionFilterProps={optionFilterProps}
|
||||
paginate={paginate}
|
||||
selects={selects}
|
||||
loading={loading ?? false}
|
||||
dropdownStyle={popupStyle}
|
||||
/>
|
||||
<span key={key} data-test="select-filter-container">
|
||||
<CompactFilterTrigger
|
||||
label={Header}
|
||||
hasValue={!!selectValue}
|
||||
tooltipTitle={tooltipTitle}
|
||||
onClear={() => clearFilterAtIndex(index)}
|
||||
>
|
||||
{({ isOpen, onClose }) => (
|
||||
<CompactSelectPanel
|
||||
ref={filterRefs[index]}
|
||||
selects={selects}
|
||||
fetchSelects={fetchSelects}
|
||||
value={initialValue as SelectOption | undefined}
|
||||
loading={loading ?? false}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelStyle={popupStyle}
|
||||
onSelect={(
|
||||
option: SelectOption | undefined,
|
||||
isClear?: boolean,
|
||||
) => {
|
||||
if (option && !isClear) {
|
||||
setTooltipLabels(prev => ({
|
||||
...prev,
|
||||
[index]:
|
||||
typeof option.label === 'string'
|
||||
? option.label
|
||||
: String(option.value ?? ''),
|
||||
}));
|
||||
}
|
||||
if (onFilterUpdate && !isClear) {
|
||||
onFilterUpdate(option);
|
||||
}
|
||||
updateFilterValue(index, option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (input === 'search' && typeof Header === 'string') {
|
||||
if (searchFilterRendered) return null;
|
||||
searchFilterRendered = true;
|
||||
return (
|
||||
<SearchFilter
|
||||
ref={filterRefs[index]}
|
||||
@@ -145,30 +210,81 @@ function UIFilters(
|
||||
);
|
||||
}
|
||||
if (input === 'datetime_range') {
|
||||
const hasDateValue =
|
||||
Array.isArray(initialValue) && initialValue.some(Boolean);
|
||||
const dateTooltip = hasDateValue
|
||||
? (initialValue as (string | number)[])
|
||||
.filter(Boolean)
|
||||
.map(v => {
|
||||
if (typeof v === 'number') {
|
||||
// unix milliseconds → human-readable date
|
||||
return extendedDayjs(v).format('MMM D, YYYY HH:mm');
|
||||
}
|
||||
// ISO string — already readable
|
||||
return String(v);
|
||||
})
|
||||
.join(' – ')
|
||||
: undefined;
|
||||
return (
|
||||
<DateRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
<CompactFilterTrigger
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
dateFilterValueType={dateFilterValueType || 'unix'}
|
||||
/>
|
||||
label={Header}
|
||||
hasValue={hasDateValue}
|
||||
tooltipTitle={dateTooltip}
|
||||
popupType="dialog"
|
||||
onClear={() => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
}}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<FilterPopoverContent onClose={onClose}>
|
||||
<DateRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
dateFilterValueType={dateFilterValueType || 'unix'}
|
||||
/>
|
||||
</FilterPopoverContent>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
);
|
||||
}
|
||||
if (input === 'numerical_range') {
|
||||
const hasRangeValue =
|
||||
Array.isArray(initialValue) &&
|
||||
initialValue.some(v => v !== null && v !== undefined);
|
||||
const rangeTooltip = hasRangeValue
|
||||
? (initialValue as (number | null | undefined)[])
|
||||
.filter(v => v !== null && v !== undefined)
|
||||
.join(' – ')
|
||||
: undefined;
|
||||
return (
|
||||
<NumericalRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
min={min}
|
||||
max={max}
|
||||
<CompactFilterTrigger
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
/>
|
||||
label={Header}
|
||||
hasValue={hasRangeValue}
|
||||
tooltipTitle={rangeTooltip}
|
||||
popupType="dialog"
|
||||
onClear={() => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
}}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<FilterPopoverContent onClose={onClose}>
|
||||
<NumericalRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
min={min}
|
||||
max={max}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
/>
|
||||
</FilterPopoverContent>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -301,15 +301,19 @@ describe('ListView', () => {
|
||||
});
|
||||
|
||||
test('renders UI filters', () => {
|
||||
const filterControls = screen.getAllByRole('combobox');
|
||||
expect(filterControls).toHaveLength(2);
|
||||
// select and datetime_range filters render as compact pill buttons;
|
||||
// search filter renders as a text input
|
||||
const filterPills = screen.getAllByTestId('compact-filter-pill');
|
||||
expect(filterPills).toHaveLength(3); // ID, Age, Time
|
||||
});
|
||||
|
||||
test('calls fetchData on filter', async () => {
|
||||
// Handle select filter
|
||||
const selectFilter = screen.getAllByRole('combobox')[0];
|
||||
await userEvent.click(selectFilter);
|
||||
const option = screen.getByText('foo');
|
||||
// Click the ID compact pill to open its option panel
|
||||
const idPill = screen.getByRole('button', { name: 'ID' });
|
||||
await userEvent.click(idPill);
|
||||
|
||||
// Wait for and click the 'foo' option in the dropdown panel
|
||||
const option = await screen.findByRole('option', { name: 'foo' });
|
||||
await userEvent.click(option);
|
||||
|
||||
// Handle search filter
|
||||
@@ -341,7 +345,10 @@ describe('ListView', () => {
|
||||
initialSort: [{ id: 'something' }],
|
||||
});
|
||||
|
||||
const sortSelect = screen.getByTestId('card-sort-select');
|
||||
const sortSelectContainer = screen.getByTestId('card-sort-select');
|
||||
const sortSelect = sortSelectContainer.querySelector(
|
||||
'[data-test="compact-filter-pill"]',
|
||||
) as HTMLElement;
|
||||
await userEvent.click(sortSelect);
|
||||
|
||||
const sortOption = screen.getByText('Alphabetical');
|
||||
|
||||
@@ -65,13 +65,30 @@ const ListViewStyles = styled.div`
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: ${theme.sizeUnit * 4}px;
|
||||
|
||||
& .controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: ${theme.sizeUnit * 7}px;
|
||||
row-gap: ${theme.sizeUnit * 4}px;
|
||||
align-items: center;
|
||||
column-gap: ${theme.sizeUnit * 2}px;
|
||||
row-gap: ${theme.sizeUnit * 2}px;
|
||||
|
||||
[data-test='search-filter-container'] {
|
||||
width: ${theme.sizeUnit * 44}px;
|
||||
flex-shrink: 0;
|
||||
height: ${theme.controlHeight}px;
|
||||
justify-content: center;
|
||||
|
||||
label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +184,6 @@ const bulkSelectColumnConfig = {
|
||||
const ViewModeContainer = styled.div`
|
||||
${({ theme }) => `
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
margin-top: ${theme.sizeUnit * 5 + 1}px;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
|
||||
@@ -192,6 +208,29 @@ const ViewModeContainer = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const ClearAllButton = styled.button`
|
||||
${({ theme }) => `
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
color: ${theme.colorPrimary};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
line-height: ${theme.controlHeight}px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${theme.colorPrimaryHover};
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${theme.colorTextDisabled};
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const EmptyWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.sizeUnit * 40}px 0;
|
||||
@@ -356,6 +395,14 @@ export function ListView<T extends object = any>({
|
||||
clearFilterById: (id: string) => void;
|
||||
}>(null);
|
||||
|
||||
const hasActiveFilters = internalFilters.some(f => {
|
||||
if (f.value === null || f.value === undefined || f.value === '')
|
||||
return false;
|
||||
if (Array.isArray(f.value))
|
||||
return f.value.some(v => v !== null && v !== undefined && v !== '');
|
||||
return true;
|
||||
});
|
||||
|
||||
// Wire the optional external filtersRef to our internal filterControlsRef.
|
||||
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
|
||||
// ref is populated before the first paint and after every update.
|
||||
@@ -421,6 +468,21 @@ export function ListView<T extends object = any>({
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && (
|
||||
<Tooltip
|
||||
title={!hasActiveFilters ? t('No filters applied') : undefined}
|
||||
>
|
||||
<span>
|
||||
<ClearAllButton
|
||||
type="button"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={() => filterControlsRef.current?.clearFilters()}
|
||||
>
|
||||
{t('Clear all')}
|
||||
</ClearAllButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
|
||||
import { GetState, LayoutItem, RootState } from '../types';
|
||||
import { updateLayoutComponents } from './dashboardFilters';
|
||||
import { setUnsavedChanges } from './dashboardState';
|
||||
|
||||
type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
|
||||
|
||||
// Component CRUD -------------------------------------------------------------
|
||||
|
||||
@@ -118,7 +118,7 @@ const NewChartButtonContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: ${theme.sizeUnit * 2}px;
|
||||
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -90,13 +90,7 @@ const defaultState = {
|
||||
superset_can_explore: false,
|
||||
superset_can_share: false,
|
||||
superset_can_csv: false,
|
||||
common: {
|
||||
conf: {
|
||||
SUPERSET_WEBSERVER_TIMEOUT: 0,
|
||||
SQL_MAX_ROW: 666,
|
||||
TABLE_VIZ_MAX_ROW_SERVER: 999,
|
||||
},
|
||||
},
|
||||
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0, SQL_MAX_ROW: 666 } },
|
||||
},
|
||||
dashboardLayout: {
|
||||
present: {},
|
||||
@@ -207,7 +201,7 @@ test('should call exportChart when exportCSV is clicked', async () => {
|
||||
stubbedExportCSV.mockRestore();
|
||||
});
|
||||
|
||||
test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when exportFullCSV is clicked', async () => {
|
||||
test('should call exportChart with row_limit props.maxRows when exportFullCSV is clicked', async () => {
|
||||
(global as any).featureFlags = {
|
||||
[FeatureFlag.AllowFullCsvExport]: true,
|
||||
};
|
||||
@@ -228,8 +222,7 @@ test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when expor
|
||||
expect(stubbedExportCSV).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: expect.objectContaining({
|
||||
row_limit: 999,
|
||||
full_export: true,
|
||||
row_limit: 666,
|
||||
dashboardId: 111,
|
||||
}),
|
||||
resultType: 'full',
|
||||
@@ -263,7 +256,7 @@ test('should call exportChart when exportXLSX is clicked', async () => {
|
||||
stubbedExportXLSX.mockRestore();
|
||||
});
|
||||
|
||||
test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when exportFullXLSX is clicked', async () => {
|
||||
test('should call exportChart with row_limit props.maxRows when exportFullXLSX is clicked', async () => {
|
||||
(global as any).featureFlags = {
|
||||
[FeatureFlag.AllowFullCsvExport]: true,
|
||||
};
|
||||
@@ -284,8 +277,7 @@ test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when expor
|
||||
expect(stubbedExportXLSX).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: expect.objectContaining({
|
||||
row_limit: 999,
|
||||
full_export: true,
|
||||
row_limit: 666,
|
||||
dashboardId: 111,
|
||||
}),
|
||||
resultType: 'full',
|
||||
|
||||
@@ -224,10 +224,8 @@ const Chart = (props: ChartProps) => {
|
||||
const emitCrossFilters = useSelector(
|
||||
(state: RootState) => !!state.dashboardInfo.crossFiltersEnabled,
|
||||
);
|
||||
const fullExportMaxRows: number = useSelector(
|
||||
(state: RootState) =>
|
||||
(state.dashboardInfo.common.conf.TABLE_VIZ_MAX_ROW_SERVER as number) ||
|
||||
(state.dashboardInfo.common.conf.SQL_MAX_ROW as number),
|
||||
const maxRows: number = useSelector(
|
||||
(state: RootState) => state.dashboardInfo.common.conf.SQL_MAX_ROW as number,
|
||||
);
|
||||
const streamingThreshold: number = useSelector(
|
||||
(state: RootState) =>
|
||||
@@ -482,7 +480,7 @@ const Chart = (props: ChartProps) => {
|
||||
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
||||
|
||||
const exportTable = useCallback(
|
||||
(format: string, isFullExport: boolean, isPivot = false) => {
|
||||
(format: string, isFullCSV: boolean, isPivot = false) => {
|
||||
const logAction =
|
||||
format === 'csv'
|
||||
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
|
||||
@@ -492,11 +490,8 @@ const Chart = (props: ChartProps) => {
|
||||
is_cached: isCached,
|
||||
});
|
||||
|
||||
// For a "full" export, raise the requested row_limit and flag the
|
||||
// request with full_export so the backend lifts the row-limit cap to
|
||||
// TABLE_VIZ_MAX_ROW_SERVER (gated by the ALLOW_FULL_CSV_EXPORT flag).
|
||||
const exportFormData = isFullExport
|
||||
? { ...formData, row_limit: fullExportMaxRows, full_export: true }
|
||||
const exportFormData = isFullCSV
|
||||
? { ...formData, row_limit: maxRows }
|
||||
: formData;
|
||||
const resultType = isPivot ? 'post_processed' : 'full';
|
||||
|
||||
@@ -584,7 +579,7 @@ const Chart = (props: ChartProps) => {
|
||||
sliceVizType,
|
||||
isCached,
|
||||
formData,
|
||||
fullExportMaxRows,
|
||||
maxRows,
|
||||
dataMaskOwnState,
|
||||
chartState,
|
||||
props.id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user