mirror of
https://github.com/apache/superset.git
synced 2026-06-10 01:59:17 +00:00
Compare commits
109 Commits
semantic-l
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6cf25ce0 | ||
|
|
55203bbc74 | ||
|
|
2fa3bbd91c | ||
|
|
168b49bf34 | ||
|
|
838ac8f553 | ||
|
|
42668cf634 | ||
|
|
8d985d223b | ||
|
|
e57387098b | ||
|
|
af6ac4d09c | ||
|
|
f8e13770fc | ||
|
|
5cdd542ae5 | ||
|
|
e40648dfcb | ||
|
|
c728b4a11f | ||
|
|
0febe32dc9 | ||
|
|
2a0ebd7055 | ||
|
|
6e23e4541d | ||
|
|
5af8fe77fa | ||
|
|
3599c78a03 | ||
|
|
d97b5d6509 | ||
|
|
869ab37f59 | ||
|
|
3b4892c48c | ||
|
|
91d96419fe | ||
|
|
42149f6a78 | ||
|
|
c945ef6763 | ||
|
|
21059b54f0 | ||
|
|
8ab4695ba3 | ||
|
|
b0d26196fc | ||
|
|
df8222ffcd | ||
|
|
64f0e88de7 | ||
|
|
f4af6a2caf | ||
|
|
31087177ab | ||
|
|
8e98ca6569 | ||
|
|
f7f6c29adf | ||
|
|
a94edfe418 | ||
|
|
5549100601 | ||
|
|
558ff4452b | ||
|
|
5966bb1c1e | ||
|
|
ac035083d7 | ||
|
|
e25d708197 | ||
|
|
48cb3f5885 | ||
|
|
dcef6f8a41 | ||
|
|
f09fd63495 | ||
|
|
bc26006a43 | ||
|
|
8b483f320e | ||
|
|
89c2a47433 | ||
|
|
b8b91574e0 | ||
|
|
5526464def | ||
|
|
73f66e4c14 | ||
|
|
f187a8e1c4 | ||
|
|
4c3f65ef0b | ||
|
|
53d8e5bdfa | ||
|
|
2f95d288dd | ||
|
|
2f5fcc21f9 | ||
|
|
d1d07112aa | ||
|
|
e3711bec39 | ||
|
|
ce9cab098f | ||
|
|
a183582291 | ||
|
|
3acef94ef6 | ||
|
|
9638eecdb1 | ||
|
|
7e74fc4192 | ||
|
|
cdca6f7fdc | ||
|
|
b1ca8cac6b | ||
|
|
2cd5efa627 | ||
|
|
a273fe4d62 | ||
|
|
d203f0de33 | ||
|
|
a75f9b67b2 | ||
|
|
3f0858e35d | ||
|
|
68c145adc3 | ||
|
|
4a9aecda4a | ||
|
|
46b2d7d7a9 | ||
|
|
f8600471fa | ||
|
|
b23c65e04f | ||
|
|
aa8255c55c | ||
|
|
10b7bfc8c1 | ||
|
|
89cab1860e | ||
|
|
b7585122c8 | ||
|
|
f2d80a183e | ||
|
|
69adecd6a3 | ||
|
|
fbffae0444 | ||
|
|
6ce7c2e8de | ||
|
|
105820f1f4 | ||
|
|
92b1b0a219 | ||
|
|
c39a47cbac | ||
|
|
dacda71f77 | ||
|
|
12a21c8933 | ||
|
|
13fa3810a8 | ||
|
|
3356f4d3e1 | ||
|
|
4a17c49d74 | ||
|
|
ea1ce7140c | ||
|
|
038414ea5c | ||
|
|
5bb54cc96b | ||
|
|
fb276b08dd | ||
|
|
6e8b3bf976 | ||
|
|
55024e8f4d | ||
|
|
b98bd2a07a | ||
|
|
0a3a35018c | ||
|
|
e6179036ec | ||
|
|
81b4d580db | ||
|
|
9acfac1523 | ||
|
|
aa9af6c307 | ||
|
|
fbb3056508 | ||
|
|
ffbce27c9b | ||
|
|
fe8b218a5f | ||
|
|
f5fe9bfa26 | ||
|
|
7f1c47521e | ||
|
|
0fffa74bc6 | ||
|
|
738ebf9cc6 | ||
|
|
98dff2e170 | ||
|
|
b5ad4a7a07 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -38,7 +38,7 @@
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke
|
||||
/superset/translations/ @sfirke @rusackas
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
|
||||
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' }}
|
||||
|
||||
66
.github/dependabot.yml
vendored
66
.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,16 +58,25 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 30
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 10
|
||||
# Bump the lower bound to the new version, not just widen the upper
|
||||
# bound. Without this, a `sqlglot>=28.10.0, <29` constraint upgraded
|
||||
# to `<30` would keep the stale lower bound forever, dragging
|
||||
# transitively-resolved versions with it. See #40186 (review thread).
|
||||
versioning-strategy: increase
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- pip
|
||||
- dependabot
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: ".github/actions"
|
||||
@@ -74,6 +84,8 @@ updates:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
@@ -97,6 +109,8 @@ updates:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/"
|
||||
@@ -106,6 +120,8 @@ updates:
|
||||
- npm
|
||||
- dependabot
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/utils/client-ws-app/"
|
||||
@@ -116,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!
|
||||
|
||||
@@ -128,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/"
|
||||
@@ -138,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/"
|
||||
@@ -148,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/"
|
||||
@@ -161,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/"
|
||||
@@ -171,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/"
|
||||
@@ -181,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/"
|
||||
@@ -191,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/"
|
||||
@@ -201,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/"
|
||||
@@ -214,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/"
|
||||
@@ -224,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/"
|
||||
@@ -234,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/"
|
||||
@@ -244,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/"
|
||||
@@ -254,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/"
|
||||
@@ -264,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/"
|
||||
@@ -274,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/"
|
||||
@@ -284,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/"
|
||||
@@ -294,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/"
|
||||
@@ -304,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/"
|
||||
@@ -318,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/"
|
||||
@@ -328,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/"
|
||||
@@ -338,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/"
|
||||
@@ -353,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/"
|
||||
@@ -363,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
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check.outputs.superset-extensions-cli
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -128,7 +128,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -331,6 +331,10 @@ Note: Pillow is now a required dependency (previously optional) to support image
|
||||
- [31590](https://github.com/apache/superset/pull/31590) Marks the beginning of intricate work around supporting dynamic Theming, and breaks support for [THEME_OVERRIDES](https://github.com/apache/superset/blob/732de4ac7fae88e29b7f123b6cbb2d7cd411b0e4/superset/config.py#L671) in favor of a new theming system based on AntD V5. Likely this will be in disrepair until settling over the 5.x lifecycle.
|
||||
- [32432](https://github.com/apache/superset/pull/32432) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed.
|
||||
- [34319](https://github.com/apache/superset/pull/34319) Drill to Detail and Drill By is now supported in Embedded mode, and also with the `DASHBOARD_RBAC` FF. If you don't want to expose these features in Embedded / `DASHBOARD_RBAC`, make sure the roles used for Embedded / `DASHBOARD_RBAC`don't have the required permissions to perform D2D actions.
|
||||
- If you use **MySQL** as the metadata database, the Security menu that list roles and users might be missing due to case naming changed from 'security' to 'Security'. To fix said issue, run below query on the **MySQL** metadata database:
|
||||
```sql
|
||||
UPDATE ab_view_menu SET name='Security' WHERE name='security';
|
||||
```
|
||||
|
||||
## 5.0.0
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ Screenshots will be taken but no messages actually sent as long as `ALERT_REPORT
|
||||
#### In your `Dockerfile`
|
||||
|
||||
You'll need to extend the Superset image to include a headless browser. Your options include:
|
||||
- Use Playwright with Chrome: this is the recommended approach as of version 4.1.x or greater. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Read the code comments there as you'll also need to change a feature flag in your config.
|
||||
- Use Firefox: you'll need to install geckodriver and Firefox.
|
||||
- Use Chrome without Playwright: you'll need to install Chrome and set the value of `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
- Use Playwright with Chromium: this is the recommended approach as of version 4.1.x or greater. Playwright always uses Chromium — the `WEBDRIVER_TYPE` config setting has no effect when Playwright is active. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Enable the `PLAYWRIGHT_REPORTS_AND_THUMBNAILS` feature flag in your config to activate it.
|
||||
- Use Firefox (Selenium): you'll need to install geckodriver and Firefox. Set `WEBDRIVER_TYPE` to `"firefox"` in your `superset_config.py`.
|
||||
- Use Chrome (Selenium): you'll need to install Chrome. Set `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
|
||||
In Superset versions <=4.0x, users installed Firefox or Chrome and that was documented here.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.30",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
@@ -109,8 +109,8 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"webpack": "^5.106.2"
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -206,12 +206,26 @@ async function downloadBadge(url, staticDir) {
|
||||
badgeCache.set(url, webPath);
|
||||
return webPath;
|
||||
} catch (error) {
|
||||
// Fail the build on badge download failure
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges. Please fix the badge URL or remove it.`,
|
||||
// Soft fallback: keep the original remote URL in the rendered output
|
||||
// so the badge still appears for readers, and the docs build continues.
|
||||
// External badge services (notably img.shields.io) rate-limit CI IPs
|
||||
// aggressively, and a transient fetch failure shouldn't take the whole
|
||||
// docs build down with it. Set REMARK_BADGES_STRICT=true to opt back
|
||||
// into hard-fail-the-build behavior (e.g. for release builds where you
|
||||
// want to catch genuinely broken badge URLs).
|
||||
if (process.env.REMARK_BADGES_STRICT === 'true') {
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges (REMARK_BADGES_STRICT=true).`,
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
`[remark-localize-badges] Could not localize ${url} ` +
|
||||
`(${error.message}); falling back to remote URL.`,
|
||||
);
|
||||
badgeCache.set(url, url);
|
||||
return url;
|
||||
} finally {
|
||||
// Clean up the in-flight tracker
|
||||
inFlightDownloads.delete(url);
|
||||
|
||||
246
docs/yarn.lock
246
docs/yarn.lock
@@ -4455,22 +4455,6 @@
|
||||
dependencies:
|
||||
"@types/ms" "*"
|
||||
|
||||
"@types/eslint-scope@^3.7.7":
|
||||
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==
|
||||
dependencies:
|
||||
"@types/eslint" "*"
|
||||
"@types/estree" "*"
|
||||
|
||||
"@types/eslint@*":
|
||||
version "9.6.1"
|
||||
resolved "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz"
|
||||
integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
"@types/json-schema" "*"
|
||||
|
||||
"@types/estree-jsx@^1.0.0":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"
|
||||
@@ -4612,7 +4596,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz"
|
||||
integrity sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==
|
||||
|
||||
"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
@@ -4828,100 +4812,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.3", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz#5d6da7e7b236b46452fa00d3904bb6f59615bfde"
|
||||
integrity sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==
|
||||
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
|
||||
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/type-utils" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/type-utils" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.3", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.3.tgz#f46cbc70ae0a25119ef94eac9ecd46714788e1a1"
|
||||
integrity sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==
|
||||
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
|
||||
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz#1be5ae152aad987a156c9a1a9b4256e75cfbbe0c"
|
||||
integrity sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==
|
||||
"@typescript-eslint/project-service@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
|
||||
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.3"
|
||||
"@typescript-eslint/types" "^8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.4"
|
||||
"@typescript-eslint/types" "^8.59.4"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz#91a60f66803fe9dae0696fbab2451f5723f119d2"
|
||||
integrity sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==
|
||||
"@typescript-eslint/scope-manager@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
|
||||
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.3", "@typescript-eslint/tsconfig-utils@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz#88ca9036b42ccdd1e630cfdafd2e042c2ca6a835"
|
||||
integrity sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
|
||||
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz#421fb2448bdfeb301d134a01cd02503f67fd8192"
|
||||
integrity sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==
|
||||
"@typescript-eslint/type-utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
|
||||
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.3", "@typescript-eslint/types@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.3.tgz#b7ca539c5e302fdde9a7cadb73caed107ef8f2cd"
|
||||
integrity sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==
|
||||
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
|
||||
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz#e6bb1408e00b47e431427a40268db4e86cb121ab"
|
||||
integrity sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==
|
||||
"@typescript-eslint/typescript-estree@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
|
||||
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/project-service" "8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.3.tgz#f693f979deb4dc3994de03ff8b23976d625c36c5"
|
||||
integrity sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==
|
||||
"@typescript-eslint/utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
|
||||
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz#820843b1b5ca4290009cf189382abcf6fe00dfa6"
|
||||
integrity sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==
|
||||
"@typescript-eslint/visitor-keys@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
|
||||
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5584,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.30, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.30"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz#58915c74388b05f3b3504026194ea9fa98f6e6b6"
|
||||
integrity sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==
|
||||
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.31"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
|
||||
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7269,13 +7253,13 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.20.0:
|
||||
version "5.20.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d"
|
||||
integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==
|
||||
enhanced-resolve@^5.21.4:
|
||||
version "5.21.5"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
|
||||
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.3.0"
|
||||
tapable "^2.3.3"
|
||||
|
||||
entities@^2.0.0:
|
||||
version "2.2.0"
|
||||
@@ -7391,10 +7375,10 @@ es-iterator-helpers@^1.2.1:
|
||||
iterator.prototype "^1.1.4"
|
||||
safe-array-concat "^1.1.3"
|
||||
|
||||
es-module-lexer@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
|
||||
integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==
|
||||
es-module-lexer@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c"
|
||||
integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==
|
||||
|
||||
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||
version "1.1.1"
|
||||
@@ -9655,10 +9639,10 @@ liquid-json@0.3.1:
|
||||
resolved "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz"
|
||||
integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==
|
||||
|
||||
loader-runner@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz"
|
||||
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
|
||||
loader-runner@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9"
|
||||
integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==
|
||||
|
||||
loader-utils@^1.2.3:
|
||||
version "1.4.2"
|
||||
@@ -12286,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==
|
||||
@@ -14128,15 +14105,15 @@ synckit@^0.11.12:
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"
|
||||
integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
||||
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
||||
|
||||
terser-webpack-plugin@^5.3.17, terser-webpack-plugin@^5.3.9:
|
||||
version "5.3.17"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz#75ea98876297fbb190d2fbb395e982582b859a67"
|
||||
integrity sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==
|
||||
terser-webpack-plugin@^5.3.9, terser-webpack-plugin@^5.5.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3"
|
||||
integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "^0.3.25"
|
||||
jest-worker "^27.4.5"
|
||||
@@ -14407,15 +14384,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.3:
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz#4a41d9007faa539a66292189e2795eeb0b9fca29"
|
||||
integrity sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==
|
||||
typescript-eslint@^8.59.4:
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
|
||||
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.3"
|
||||
"@typescript-eslint/parser" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/eslint-plugin" "8.59.4"
|
||||
"@typescript-eslint/parser" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
@@ -14970,22 +14947,21 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
|
||||
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
|
||||
webpack-sources@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
|
||||
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "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.106.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.106.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.2.tgz#ca8174b4fd80f055cc5a45fcc5577d6db76c8ac5"
|
||||
integrity sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==
|
||||
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/eslint-scope" "^3.7.7"
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
"@webassemblyjs/ast" "^1.14.1"
|
||||
@@ -14995,20 +14971,20 @@ webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
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"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
|
||||
@@ -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",
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -92,6 +92,26 @@ class Dimension:
|
||||
grain: Grain | None = None
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
"""
|
||||
Aggregation function applied by a metric.
|
||||
|
||||
Additivity (across an arbitrary set of grouping dimensions):
|
||||
* ``SUM``, ``COUNT``: fully additive — sub-group sums roll up via ``sum``.
|
||||
* ``MIN``, ``MAX``: roll up via ``min`` / ``max`` of sub-group values.
|
||||
* ``AVG``, ``COUNT_DISTINCT``, ``OTHER``: not safely roll-uppable from
|
||||
sub-aggregates without auxiliary data.
|
||||
"""
|
||||
|
||||
SUM = "SUM"
|
||||
COUNT = "COUNT"
|
||||
MIN = "MIN"
|
||||
MAX = "MAX"
|
||||
AVG = "AVG"
|
||||
COUNT_DISTINCT = "COUNT_DISTINCT"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metric:
|
||||
id: str
|
||||
@@ -100,6 +120,7 @@ class Metric:
|
||||
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -17,12 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { PropsWithChildren, ReactNode, SyntheticEvent } from 'react';
|
||||
import {
|
||||
ResizableBox,
|
||||
ResizableBoxProps,
|
||||
ResizeCallbackData,
|
||||
} from 'react-resizable';
|
||||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
import { ResizableBox, ResizeCallbackData } from 'react-resizable';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
|
||||
import 'react-resizable/css/styles.css';
|
||||
@@ -46,14 +42,16 @@ export type Size = ResizeCallbackData['size'];
|
||||
|
||||
export default function ResizablePanel({
|
||||
children,
|
||||
heading = undefined,
|
||||
heading,
|
||||
initialSize = { width: 500, height: 300 },
|
||||
minConstraints = [100, 100] as [number, number],
|
||||
onResize,
|
||||
...props
|
||||
}: PropsWithChildren<Omit<ResizableBoxProps, 'width' | 'height'>> & {
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
heading?: ReactNode;
|
||||
initialSize?: Size;
|
||||
minConstraints?: [number, number];
|
||||
onResize?: (e: SyntheticEvent, data: ResizeCallbackData) => void;
|
||||
}) {
|
||||
const { width, height } = initialSize;
|
||||
return (
|
||||
@@ -61,16 +59,14 @@ export default function ResizablePanel({
|
||||
className="panel"
|
||||
width={width}
|
||||
height={height}
|
||||
axis="both"
|
||||
minConstraints={minConstraints}
|
||||
onResize={
|
||||
onResize
|
||||
? (e: SyntheticEvent, data: ResizeCallbackData) => {
|
||||
const { size } = data;
|
||||
onResize(e, { ...data, size });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
maxConstraints={[Infinity, Infinity]}
|
||||
handleSize={[20, 20]}
|
||||
lockAspectRatio={false}
|
||||
resizeHandles={['se']}
|
||||
transformScale={1}
|
||||
onResize={onResize}
|
||||
>
|
||||
<>
|
||||
{heading ? <div className="panel-heading">{heading}</div> : null}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
577
superset-frontend/package-lock.json
generated
577
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
@@ -166,7 +168,7 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^1.1.0",
|
||||
"content-disposition": "^2.0.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -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",
|
||||
@@ -190,8 +192,8 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"markdown-to-jsx": "^9.8.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -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",
|
||||
@@ -289,12 +291,12 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-resizable": "^4.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -303,14 +305,14 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"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",
|
||||
@@ -350,13 +352,13 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.65.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-resizable": "^4.0.1",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -365,14 +367,14 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.0",
|
||||
"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",
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,8 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
return BigInt(value);
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -183,6 +183,26 @@ describe('parseResponse()', () => {
|
||||
expect(responseBigNumber.json.constructor).toEqual('constructor');
|
||||
});
|
||||
|
||||
test('handles big numbers in scientific notation when `parseMethod=json-bigint`', async () => {
|
||||
const mockScientificUrl = '/mock/get/scientific';
|
||||
const mockScientificPayload =
|
||||
'{ "big_double": 4.799703045723905e+32, "negative_big": -4.799703045723905e+32, "small": 1 }';
|
||||
fetchMock.get(mockScientificUrl, mockScientificPayload);
|
||||
|
||||
const responseBigNumber = await parseResponse(
|
||||
callApi({ url: mockScientificUrl, method: 'GET' }),
|
||||
'json-bigint',
|
||||
);
|
||||
|
||||
expect(`${responseBigNumber.json.big_double}`).toEqual(
|
||||
'479970304572390500000000000000000',
|
||||
);
|
||||
expect(`${responseBigNumber.json.negative_big}`).toEqual(
|
||||
'-479970304572390500000000000000000',
|
||||
);
|
||||
expect(responseBigNumber.json.small).toEqual(1);
|
||||
});
|
||||
|
||||
test('rejects if request.ok=false', async () => {
|
||||
expect.assertions(3);
|
||||
const mockNotOkayUrl = '/mock/notokay/url';
|
||||
|
||||
@@ -95,8 +95,11 @@ class FakeMessageChannel {
|
||||
const port2 = new FakeMessagePort();
|
||||
port1.otherPort = port2;
|
||||
port2.otherPort = port1;
|
||||
this.port1 = port1;
|
||||
this.port2 = port2;
|
||||
// FakeMessagePort only implements the subset of MessagePort that
|
||||
// Switchboard exercises; cast at the boundary so the fake satisfies
|
||||
// the consumer signature without weakening the production type.
|
||||
this.port1 = port1 as unknown as MessagePort;
|
||||
this.port2 = port2 as unknown as MessagePort;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function isError(message: Message): message is ErrorMessage {
|
||||
* Calling methods on the switchboard causes messages to be sent through the channel.
|
||||
*/
|
||||
export class Switchboard {
|
||||
port: MessagePort;
|
||||
port!: MessagePort;
|
||||
|
||||
name = '';
|
||||
|
||||
@@ -97,9 +97,9 @@ export class Switchboard {
|
||||
// used to make unique ids
|
||||
incrementor = 1;
|
||||
|
||||
debugMode: boolean;
|
||||
debugMode = false;
|
||||
|
||||
private isInitialised: boolean;
|
||||
private isInitialised = false;
|
||||
|
||||
constructor(params?: Params) {
|
||||
if (!params) {
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
VisualMapComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
type DataZoomComponentOption,
|
||||
ToolboxComponent,
|
||||
GraphicComponent,
|
||||
AriaComponent,
|
||||
@@ -280,12 +281,56 @@ function Echart(
|
||||
|
||||
const notMerge = !isDashboardRefreshing;
|
||||
chartRef.current?.dispatchAction({ type: 'hideTip' });
|
||||
// setOption(notMerge:true) replaces the dataZoom config, dropping any
|
||||
// range the user has engaged. Preserve it across the call.
|
||||
const previousZoom = notMerge
|
||||
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
|
||||
?.dataZoom
|
||||
: undefined;
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
replaceMerge: notMerge ? undefined : ['series'],
|
||||
// lazyUpdate defers render, causing tooltip crashes on stale shapes (#39247)
|
||||
lazyUpdate: false,
|
||||
});
|
||||
if (previousZoom?.length) {
|
||||
// Skip restore when the new option reshapes dataZoom (different count
|
||||
// means index-based restore could land on the wrong component).
|
||||
const newZoom = (
|
||||
chartRef.current?.getOption() as {
|
||||
dataZoom?: DataZoomComponentOption[];
|
||||
}
|
||||
)?.dataZoom;
|
||||
if (newZoom?.length === previousZoom.length) {
|
||||
const batch = previousZoom
|
||||
.map((dz, dataZoomIndex) => ({
|
||||
dataZoomIndex,
|
||||
start: dz.start,
|
||||
end: dz.end,
|
||||
startValue: dz.startValue,
|
||||
endValue: dz.endValue,
|
||||
}))
|
||||
.filter(b => {
|
||||
const hasAny =
|
||||
b.start !== undefined ||
|
||||
b.end !== undefined ||
|
||||
b.startValue !== undefined ||
|
||||
b.endValue !== undefined;
|
||||
if (!hasAny) return false;
|
||||
// Default full-range zoom is functionally identical to the
|
||||
// fresh state setOption already produces — skip the dispatch.
|
||||
const isDefaultRange =
|
||||
b.start === 0 &&
|
||||
b.end === 100 &&
|
||||
b.startValue === undefined &&
|
||||
b.endValue === undefined;
|
||||
return !isDefaultRange;
|
||||
});
|
||||
if (batch.length) {
|
||||
chartRef.current?.dispatchAction({ type: 'dataZoom', batch });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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 { EChartsCoreOption } from 'echarts/core';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
|
||||
const setOption = jest.fn();
|
||||
const on = jest.fn();
|
||||
const off = jest.fn();
|
||||
const resize = jest.fn();
|
||||
const dispose = jest.fn();
|
||||
const dispatchAction = jest.fn();
|
||||
const getOption = jest.fn();
|
||||
|
||||
const mockInstance = {
|
||||
setOption,
|
||||
on,
|
||||
off,
|
||||
resize,
|
||||
dispose,
|
||||
dispatchAction,
|
||||
getOption,
|
||||
getZr: () => ({ on: jest.fn(), off: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
__esModule: true,
|
||||
use: jest.fn(),
|
||||
init: jest.fn(() => mockInstance),
|
||||
registerLocale: jest.fn(),
|
||||
}));
|
||||
jest.mock('echarts/charts', () => ({}));
|
||||
jest.mock('echarts/renderers', () => ({}));
|
||||
jest.mock('echarts/components', () => ({}));
|
||||
jest.mock('echarts/features', () => ({}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import Echart from '../../src/components/Echart';
|
||||
|
||||
const renderEchart = (echartOptions: EChartsCoreOption) => {
|
||||
const refs = { divRef: undefined };
|
||||
return render(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={echartOptions}
|
||||
refs={refs}
|
||||
/>,
|
||||
{ useRedux: true, useTheme: true },
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setOption.mockClear();
|
||||
on.mockClear();
|
||||
off.mockClear();
|
||||
resize.mockClear();
|
||||
dispatchAction.mockClear();
|
||||
getOption.mockReset();
|
||||
});
|
||||
|
||||
test('preserves user dataZoom range across setOption(notMerge)', async () => {
|
||||
// After the user has zoomed, ECharts reports the current dataZoom range
|
||||
// via getOption().dataZoom. We expect Echart to capture this before
|
||||
// setOption replaces the option payload, then restore it via dispatchAction.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ start: 12, end: 48 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
|
||||
// Trigger another setOption call by changing the echartOptions reference
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatchAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'dataZoom',
|
||||
batch: [
|
||||
expect.objectContaining({ dataZoomIndex: 0, start: 12, end: 48 }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not restore when no prior zoom range exists', async () => {
|
||||
// Fresh chart with no engaged zoom: dataZoom config has no start/end.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when prior zoom is at default full range', async () => {
|
||||
// ECharts populates start:0/end:100 on slider dataZoom by default, so
|
||||
// every untouched timeseries would otherwise dispatch a redundant action
|
||||
// on each re-render. Skip the dispatch when the range is just the default.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true, start: 0, end: 100 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when the new option reshapes dataZoom', async () => {
|
||||
// 1st render starts with no engaged zoom; 2nd render captures an engaged
|
||||
// range but the post-setOption dataZoom has a different count, so
|
||||
// index-based restore could write to the wrong component. Skip in that case.
|
||||
getOption
|
||||
// 1st render: previousZoom + newZoom (no engaged values, nothing to dispatch)
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
// 2nd render: previousZoom has user range, but newZoom has 2 entries
|
||||
.mockReturnValueOnce({ dataZoom: [{ start: 12, end: 48 }] })
|
||||
.mockReturnValueOnce({
|
||||
dataZoom: [{ start: 12, end: 48 }, { type: 'inside' }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -27,9 +27,9 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -97,7 +97,7 @@ export function createWrapper(options?: Options) {
|
||||
}
|
||||
|
||||
if (useDnd) {
|
||||
// @ts-expect-error react-dnd types not updated for React 18
|
||||
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
|
||||
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1671,7 +1671,7 @@ export interface VizOptions {
|
||||
|
||||
export function createDatasource(
|
||||
vizOptions: VizOptions,
|
||||
): SqlLabThunkAction<Promise<unknown>> {
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
|
||||
@@ -1691,9 +1691,10 @@ export function createDatasource(
|
||||
}),
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch(createDatasourceSuccess(json as { id: number }));
|
||||
const result = json as { id: number };
|
||||
dispatch(createDatasourceSuccess(result));
|
||||
|
||||
return Promise.resolve(json);
|
||||
return result;
|
||||
})
|
||||
.catch(error => {
|
||||
getClientErrorObject(error).then(e => {
|
||||
@@ -1712,7 +1713,7 @@ export function createDatasource(
|
||||
|
||||
export function createCtasDatasource(
|
||||
vizOptions: Record<string, unknown>,
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
): SqlLabThunkAction<Promise<{ table_id: number }>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
return SupersetClient.post({
|
||||
@@ -1720,9 +1721,14 @@ export function createCtasDatasource(
|
||||
jsonPayload: vizOptions,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch(createDatasourceSuccess(json.result));
|
||||
const result = json.result as { table_id: number };
|
||||
// The endpoint's `result.table_id` IS the dataset id; normalize so
|
||||
// createDatasourceSuccess's `${data.id}__table` resolves correctly.
|
||||
// Without this, the CTAS Explore button silently produced
|
||||
// `"undefined__table"` because `result.id` doesn't exist.
|
||||
dispatch(createDatasourceSuccess({ id: result.table_id }));
|
||||
|
||||
return json.result;
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
const errorMsg = t('An error occurred while creating the data source');
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import { useRef, useEffect, FC, useMemo } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
SqlLabRootState,
|
||||
@@ -86,7 +87,7 @@ const EditorAutoSync: FC = () => {
|
||||
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
|
||||
state => state.sqlLab.editorTabLastUpdatedAt,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const lastSavedTimestampRef = useRef<number>(editorTabLastUpdatedAt);
|
||||
|
||||
const currentQueryEditorId = useSelector<SqlLabRootState, string>(
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { usePrevious } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
@@ -136,7 +137,7 @@ const EditorWrapper = ({
|
||||
height,
|
||||
hotkeys,
|
||||
}: EditorWrapperProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'id',
|
||||
'dbId',
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { useStore } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
@@ -68,7 +69,7 @@ export function useKeywords(
|
||||
catalog,
|
||||
schema,
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const hasFetchedKeywords = useRef(false);
|
||||
// skipFetch is used to prevent re-evaluating memoized keywords
|
||||
// due to updated api results by skip flag
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { JsonObject, VizType } from '@superset-ui/core';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import {
|
||||
createCtasDatasource,
|
||||
addInfoToast,
|
||||
@@ -45,7 +46,7 @@ const ExploreCtasResultsButton = ({
|
||||
const errorMessage = useSelector(
|
||||
(state: SqlLabRootState) => state.sqlLab.errorMessage,
|
||||
);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const buildVizOptions = {
|
||||
table_name: table,
|
||||
@@ -56,7 +57,7 @@ const ExploreCtasResultsButton = ({
|
||||
|
||||
const visualize = () => {
|
||||
dispatch(createCtasDatasource(buildVizOptions))
|
||||
.then((data: { table_id: number }) => {
|
||||
.then(data => {
|
||||
const formData = {
|
||||
datasource: `${data.table_id}__table`,
|
||||
metrics: ['count'],
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import URI from 'urijs';
|
||||
import { pick } from 'lodash';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
@@ -49,7 +50,7 @@ const PopEditorTab: React.FC<{ children?: React.ReactNode }> = ({
|
||||
({ sqlLab: { tabHistory } }) => tabHistory.slice(-1)[0],
|
||||
);
|
||||
const [updatedUrl, setUpdatedUrl] = useState<string>(SQL_LAB_URL);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
useComponentDidUpdate(() => {
|
||||
setQueryEditorId(assigned => assigned ?? activeQueryEditorId);
|
||||
if (activeQueryEditorId) {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { isObject } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
@@ -82,7 +83,7 @@ function QueryAutoRefresh({
|
||||
.map(({ id }) => id),
|
||||
),
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const checkForRefresh = () => {
|
||||
const shouldRequestChecking = shouldCheckForQueries(queries);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Button } from '@superset-ui/core/components';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
@@ -75,7 +75,7 @@ const QueryLimitSelect = ({
|
||||
maxRow,
|
||||
defaultQueryLimit,
|
||||
}: QueryLimitSelectProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const queryEditor = useQueryEditor(queryEditorId, ['id', 'queryLimit']);
|
||||
const queryLimit = queryEditor.queryLimit || defaultQueryLimit;
|
||||
|
||||
@@ -30,7 +30,8 @@ import ProgressBar from '@superset-ui/core/components/ProgressBar';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { QueryResponse, QueryState } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
|
||||
import {
|
||||
queryEditorSetSql,
|
||||
@@ -92,7 +93,7 @@ const QueryTable = ({
|
||||
latestQueryId,
|
||||
}: QueryTableProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedQuery, setSelectedQuery] = useState<QueryResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
@@ -231,7 +232,7 @@ const ResultSet = ({
|
||||
canCopyClipboardSqlLab: canCopyClipboard,
|
||||
} = usePermissions();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
const { showConfirm, ConfirmModal } = useConfirmModal();
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import * as reactRedux from 'react-redux';
|
||||
import { act } from 'react';
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
@@ -40,6 +39,19 @@ const mockedProps = {
|
||||
datasource: testQuery,
|
||||
};
|
||||
|
||||
// Render with the SqlLab user fixture preloaded into the mock store so the
|
||||
// component's useSelector(state => state.user) returns a useful value.
|
||||
// Previously this test used jest.spyOn(reactRedux, 'useSelector') to inject
|
||||
// the user directly, which can't intercept calls routed through the typed
|
||||
// useAppSelector hook.
|
||||
const renderModal = (
|
||||
props: Partial<ComponentProps<typeof SaveDatasetModal>> = {},
|
||||
) =>
|
||||
render(<SaveDatasetModal {...mockedProps} {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: { user },
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
result: mockdatasets,
|
||||
dataset_count: 3,
|
||||
@@ -47,17 +59,17 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
// Mock the user
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
beforeEach(() => {
|
||||
useSelectorMock.mockClear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock the createDatasource action
|
||||
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
// the thunk and the production code's .then((data) => clearDatasetCache(data.id))
|
||||
// chain receives `{ id: 123 }`. Individual tests can override per-call as needed.
|
||||
jest.mock('src/SqlLab/actions/sqlLab', () => ({
|
||||
createDatasource: jest.fn(),
|
||||
createDatasource: jest.fn(() => () => Promise.resolve({ id: 123 })),
|
||||
}));
|
||||
jest.mock('src/explore/exploreUtils/formData', () => ({
|
||||
postFormData: jest.fn(),
|
||||
@@ -70,7 +82,7 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SaveDatasetModal', () => {
|
||||
test('renders a "Save as new" field', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const saveRadioBtn = screen.getByRole('radio', {
|
||||
name: /save as new/i,
|
||||
@@ -87,7 +99,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders an "Overwrite existing" field', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
name: /overwrite existing/i,
|
||||
@@ -103,20 +115,20 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a close button', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a save button when "Save as new" is selected', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// "Save as new" is selected when the modal opens by default
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders an overwrite button when "Overwrite existing" is selected', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button to reveal the overwrite confirmation and back buttons
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -130,8 +142,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders the overwrite button as disabled until an existing dataset is selected', async () => {
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -168,8 +179,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a confirm overwrite screen when overwrite is clicked', async () => {
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -215,11 +225,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the schema when creating the dataset', async () => {
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -240,17 +246,9 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the catalog when creating the dataset', async () => {
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(
|
||||
<SaveDatasetModal
|
||||
{...mockedProps}
|
||||
datasource={{ ...mockedProps.datasource, catalog: 'public' }}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
renderModal({
|
||||
datasource: { ...mockedProps.datasource, catalog: 'public' },
|
||||
});
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -271,7 +269,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('does not renders a checkbox button when template processing is disabled', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -280,7 +278,7 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -289,15 +287,11 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -324,15 +318,11 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -393,19 +383,11 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Check the "Include Template Parameters" checkbox
|
||||
@@ -443,19 +425,11 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Do NOT check the "Include Template Parameters" checkbox
|
||||
@@ -489,12 +463,9 @@ describe('SaveDatasetModal', () => {
|
||||
'postFormData',
|
||||
);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
postFormData.mockResolvedValue('chart_key_123');
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
|
||||
@@ -34,7 +34,6 @@ import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
SupersetClient,
|
||||
JsonResponse,
|
||||
JsonObject,
|
||||
QueryResponse,
|
||||
QueryFormData,
|
||||
VizType,
|
||||
@@ -44,16 +43,14 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useAppDispatch, useAppSelector } from 'src/views/store';
|
||||
import rison from 'rison';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
|
||||
import {
|
||||
DatasetRadioState,
|
||||
EXPLORE_CHART_DEFAULT,
|
||||
DatasetOwner,
|
||||
SqlLabRootState,
|
||||
} from 'src/SqlLab/types';
|
||||
import { mountExploreUrl } from 'src/explore/exploreUtils';
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
@@ -221,7 +218,7 @@ export const SaveDatasetModal = ({
|
||||
openWindow = true,
|
||||
formData = {},
|
||||
}: SaveDatasetModalProps) => {
|
||||
const defaultVizType = useSelector<SqlLabRootState, string>(
|
||||
const defaultVizType = useAppSelector(
|
||||
state => state.common?.conf?.DEFAULT_VIZ_TYPE || VizType.Table,
|
||||
);
|
||||
|
||||
@@ -240,8 +237,8 @@ export const SaveDatasetModal = ({
|
||||
>(undefined);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const user = useSelector<SqlLabRootState, User>(state => state.user);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const user = useAppSelector(state => state.user);
|
||||
const dispatch = useAppDispatch();
|
||||
const [includeTemplateParameters, setIncludeTemplateParameters] =
|
||||
useState(false);
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -105,7 +106,7 @@ const SouthPane = ({
|
||||
const { id, tabViewId } = useQueryEditor(queryEditorId, ['tabViewId']);
|
||||
const editorId = tabViewId ?? id;
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
|
||||
const { offline, tables } = useSelector(
|
||||
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user