mirror of
https://github.com/apache/superset.git
synced 2026-06-10 01:59:17 +00:00
Compare commits
67 Commits
mcp-rls-pl
...
fix/helm-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd216d64bc | ||
|
|
33585b0480 | ||
|
|
b64561f3a3 | ||
|
|
fe484f6bb2 | ||
|
|
8caa74354f | ||
|
|
9c90a6854c | ||
|
|
2fef4e41f2 | ||
|
|
965ec47296 | ||
|
|
b21450681d | ||
|
|
5003ee1499 | ||
|
|
816794b198 | ||
|
|
841871f1e7 | ||
|
|
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 |
16
.github/SECURITY.md
vendored
16
.github/SECURITY.md
vendored
@@ -33,13 +33,21 @@ We kindly ask you to include the following information in your report to assist
|
||||
- Expected vs. Actual Behavior: A clear description of the intended system behavior versus the observed vulnerability.
|
||||
- Detailed Reproduction Steps: Clear, manual steps to reproduce the vulnerability.
|
||||
|
||||
**Vulnerability Definition**
|
||||
|
||||
Apache Superset considers a security vulnerability to be a demonstrable issue that has meaningful impact on confidentiality, integrity, or availability beyond the intended security model. Low-impact boundary variations or technical edge cases in existing access controls may be classified as hardening improvements rather than vulnerabilities, even if exploitable.
|
||||
|
||||
**Out of Scope Vulnerabilities**
|
||||
|
||||
To prioritize engineering efforts on genuine architectural risks, the following scenarios are explicitly out of scope and will not be issued a CVE:
|
||||
- Attacks requiring Admin privileges: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- Brute Force and Rate Limiting: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- Theoretical attack vectors: Issues without a demonstrable, reproducible exploit path.
|
||||
- Non-Exploitable Findings: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
- **Attacks requiring Admin privileges**: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- **Brute Force and Rate Limiting**: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- **Theoretical attack vectors**: Issues without a demonstrable, reproducible exploit path.
|
||||
- **Non-Exploitable Findings**: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
- **User enumeration**: API responses, timing differences, or error messages that reveal whether user accounts, IDs, dashboards, or datasets exist.
|
||||
- **Information disclosure (low impact)**: Software version disclosure, generic error messages, stack traces without sensitive data exposure, or system configuration details that don't enable further exploitation.
|
||||
- **Resource exhaustion requiring authentication**: Denial of Service attacks that require valid user credentials and don't bypass rate limiting or resource controls.
|
||||
- **Missing security headers**: Without demonstration of a concrete exploit scenario that leverages the missing header.
|
||||
|
||||
**Outcome of Reports**
|
||||
|
||||
|
||||
9
.github/actions/setup-docker/action.yml
vendored
9
.github/actions/setup-docker/action.yml
vendored
@@ -27,6 +27,15 @@ runs:
|
||||
- name: Set up QEMU
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
with:
|
||||
# Pin the binfmt image to a specific QEMU release. The default
|
||||
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
|
||||
# QEMU's x86_64→aarch64 translator has been the proximate cause of
|
||||
# intermittent `exit code: 132` (SIGILL) failures during the arm64
|
||||
# leg of the multi-platform docker build — newer Node native modules
|
||||
# emit instructions QEMU's user-mode emulation occasionally drops on
|
||||
# the floor. Pinning a known-good release stabilises that path.
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
|
||||
61
.github/dependabot.yml
vendored
61
.github/dependabot.yml
vendored
@@ -1,7 +1,6 @@
|
||||
version: 2
|
||||
enable-beta-ecosystems: true
|
||||
updates:
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
ignore:
|
||||
@@ -10,6 +9,8 @@ updates:
|
||||
- dependency-name: anthropics/claude-code-action
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
ignore:
|
||||
@@ -57,6 +58,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 30
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
@@ -72,6 +75,8 @@ updates:
|
||||
labels:
|
||||
- pip
|
||||
- dependabot
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: ".github/actions"
|
||||
@@ -79,6 +84,8 @@ updates:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
@@ -102,6 +109,8 @@ updates:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/"
|
||||
@@ -111,6 +120,8 @@ updates:
|
||||
- npm
|
||||
- dependabot
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/utils/client-ws-app/"
|
||||
@@ -121,6 +132,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
# Now for all of our plugins and packages!
|
||||
|
||||
@@ -133,6 +146,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
|
||||
@@ -143,6 +158,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
|
||||
@@ -153,6 +170,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
|
||||
@@ -166,6 +185,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
|
||||
@@ -176,6 +197,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
|
||||
@@ -186,6 +209,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
|
||||
@@ -196,6 +221,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
|
||||
@@ -206,6 +233,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-table/"
|
||||
@@ -219,6 +248,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
|
||||
@@ -229,6 +260,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
|
||||
@@ -239,6 +272,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
|
||||
@@ -249,6 +284,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
|
||||
@@ -259,6 +296,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
|
||||
@@ -269,6 +308,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
|
||||
@@ -279,6 +320,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
|
||||
@@ -289,6 +332,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
|
||||
@@ -299,6 +344,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
|
||||
@@ -309,6 +356,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
|
||||
@@ -323,6 +372,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/generator-superset/"
|
||||
@@ -333,6 +384,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
|
||||
@@ -343,6 +396,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-core/"
|
||||
@@ -358,6 +413,8 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-switchboard/"
|
||||
@@ -368,3 +425,5 @@ updates:
|
||||
- dependabot
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
|
||||
123
.github/workflows/bashlib.sh
vendored
123
.github/workflows/bashlib.sh
vendored
@@ -175,10 +175,13 @@ cypress-run-all() {
|
||||
local APP_ROOT=$2
|
||||
cd "$GITHUB_WORKSPACE/superset-frontend/cypress-base"
|
||||
|
||||
# Start Flask and run it in background
|
||||
# --no-debugger means disable the interactive debugger on the 500 page
|
||||
# so errors can print to stderr.
|
||||
local flasklog="${HOME}/flask.log"
|
||||
# Start the Superset backend via gunicorn (not `flask run`). The Flask
|
||||
# development server is single-threaded and has no crash-recovery, so
|
||||
# heavy tests (dashboard import/export, SQL Lab) can knock it offline
|
||||
# for the rest of the run — surfacing as `ECONNREFUSED` / `socket hang up`
|
||||
# / `Missing CSRF token` cascades. Gunicorn gives us multiple workers,
|
||||
# a request timeout, and worker-recycling under load.
|
||||
local serverlog="${HOME}/superset-cypress.log"
|
||||
local port=8081
|
||||
CYPRESS_BASE_URL="http://localhost:${port}"
|
||||
if [ -n "$APP_ROOT" ]; then
|
||||
@@ -187,8 +190,58 @@ cypress-run-all() {
|
||||
fi
|
||||
export CYPRESS_BASE_URL
|
||||
|
||||
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
|
||||
local flaskProcessId=$!
|
||||
# Mirrors the args in docker/entrypoints/run-server.sh (1 worker × 20
|
||||
# gthread threads) to keep parity with production. Multi-worker
|
||||
# configurations expose timing-sensitive races in the SQL Lab → Explore
|
||||
# navigation flow under E2E. We diverge from the entrypoint on:
|
||||
# --timeout 120: heavy dashboard import/export specs exceed the 60s
|
||||
# default
|
||||
# --max-requests / --max-requests-jitter: recycle the worker under
|
||||
# test load to avoid leaks accumulating across the run
|
||||
# superset.app:create_app(): explicit factory so we don't depend on
|
||||
# FLASK_APP being exported
|
||||
nohup gunicorn \
|
||||
--bind "127.0.0.1:$port" \
|
||||
--workers 1 \
|
||||
--worker-class gthread \
|
||||
--threads 20 \
|
||||
--timeout 120 \
|
||||
--max-requests 500 \
|
||||
--max-requests-jitter 50 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"superset.app:create_app()" \
|
||||
>"$serverlog" 2>&1 </dev/null &
|
||||
local serverPid=$!
|
||||
|
||||
# Ensure the backend is cleaned up and its log is emitted even when the
|
||||
# test runner fails under `set -e`.
|
||||
trap '
|
||||
echo "::group::gunicorn log for Cypress run"
|
||||
cat "'"$serverlog"'" || true
|
||||
echo "::endgroup::"
|
||||
kill '"$serverPid"' 2>/dev/null || true
|
||||
' EXIT
|
||||
|
||||
# Wait for the backend to be ready before launching Cypress; otherwise
|
||||
# the first spec can race the server bind and see connection errors.
|
||||
local timeout=60
|
||||
say "Waiting for gunicorn server to start on port $port..."
|
||||
while [ $timeout -gt 0 ]; do
|
||||
if curl -f "http://localhost:${port}${APP_ROOT}/health" >/dev/null 2>&1; then
|
||||
say "gunicorn server is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
timeout=$((timeout - 1))
|
||||
done
|
||||
if [ $timeout -eq 0 ]; then
|
||||
echo "::error::gunicorn server failed to start within 60 seconds"
|
||||
echo "::group::Server startup log"
|
||||
cat "$serverlog"
|
||||
echo "::endgroup::"
|
||||
return 1
|
||||
fi
|
||||
|
||||
USE_DASHBOARD_FLAG=''
|
||||
if [ "$USE_DASHBOARD" = "true" ]; then
|
||||
@@ -200,13 +253,6 @@ cypress-run-all() {
|
||||
# memoryMonitorPid=$!
|
||||
python ../../scripts/cypress_run.py --parallelism $PARALLELISM --parallelism-id $PARALLEL_ID --group $PARALLEL_ID --retries 5 $USE_DASHBOARD_FLAG
|
||||
# kill $memoryMonitorPid
|
||||
|
||||
# After job is done, print out Flask log for debugging
|
||||
echo "::group::Flask log for default run"
|
||||
cat "$flasklog"
|
||||
echo "::endgroup::"
|
||||
# make sure the program exits
|
||||
kill $flaskProcessId
|
||||
}
|
||||
|
||||
playwright-install() {
|
||||
@@ -224,9 +270,11 @@ playwright-run() {
|
||||
local APP_ROOT=$1
|
||||
local TEST_PATH=$2
|
||||
|
||||
# Start Flask from the project root (same as Cypress)
|
||||
# Start the Superset backend via gunicorn from the project root.
|
||||
# See cypress-run-all() above for the rationale — the Flask dev server
|
||||
# cannot survive the dashboard import/export tests under load.
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
local flasklog="${HOME}/flask-playwright.log"
|
||||
local serverlog="${HOME}/superset-playwright.log"
|
||||
local port=8081
|
||||
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
|
||||
if [ -n "$APP_ROOT" ]; then
|
||||
@@ -235,18 +283,37 @@ playwright-run() {
|
||||
fi
|
||||
export PLAYWRIGHT_BASE_URL
|
||||
|
||||
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
|
||||
local flaskProcessId=$!
|
||||
# See cypress-run-all() above for the args rationale (1 worker × 20
|
||||
# gthread threads matching docker/entrypoints/run-server.sh, plus a
|
||||
# 120s timeout and request-recycling for heavy E2E load).
|
||||
nohup gunicorn \
|
||||
--bind "127.0.0.1:$port" \
|
||||
--workers 1 \
|
||||
--worker-class gthread \
|
||||
--threads 20 \
|
||||
--timeout 120 \
|
||||
--max-requests 500 \
|
||||
--max-requests-jitter 50 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"superset.app:create_app()" \
|
||||
>"$serverlog" 2>&1 </dev/null &
|
||||
local serverPid=$!
|
||||
|
||||
# Ensure cleanup on exit
|
||||
trap "kill $flaskProcessId 2>/dev/null || true" EXIT
|
||||
# Ensure cleanup on exit (and emit the server log on failure)
|
||||
trap '
|
||||
echo "::group::gunicorn log for Playwright run"
|
||||
cat "'"$serverlog"'" || true
|
||||
echo "::endgroup::"
|
||||
kill '"$serverPid"' 2>/dev/null || true
|
||||
' EXIT
|
||||
|
||||
# Wait for server to be ready with health check
|
||||
local timeout=60
|
||||
say "Waiting for Flask server to start on port $port..."
|
||||
say "Waiting for gunicorn server to start on port $port..."
|
||||
while [ $timeout -gt 0 ]; do
|
||||
if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
|
||||
say "Flask server is ready"
|
||||
say "gunicorn server is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
@@ -254,9 +321,9 @@ playwright-run() {
|
||||
done
|
||||
|
||||
if [ $timeout -eq 0 ]; then
|
||||
echo "::error::Flask server failed to start within 60 seconds"
|
||||
echo "::group::Flask startup log"
|
||||
cat "$flasklog"
|
||||
echo "::error::gunicorn server failed to start within 60 seconds"
|
||||
echo "::group::Server startup log"
|
||||
cat "$serverlog"
|
||||
echo "::endgroup::"
|
||||
return 1
|
||||
fi
|
||||
@@ -271,7 +338,6 @@ playwright-run() {
|
||||
if ! find "playwright/tests/${TEST_PATH}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
|
||||
echo "No test files found in ${TEST_PATH} - skipping test run"
|
||||
say "::endgroup::"
|
||||
kill $flaskProcessId
|
||||
return 0
|
||||
fi
|
||||
echo "Running tests: ${TEST_PATH}"
|
||||
@@ -288,13 +354,6 @@ playwright-run() {
|
||||
fi
|
||||
say "::endgroup::"
|
||||
|
||||
# After job is done, print out Flask log for debugging
|
||||
echo "::group::Flask log for Playwright run"
|
||||
cat "$flasklog"
|
||||
echo "::endgroup::"
|
||||
# make sure the program exits
|
||||
kill $flaskProcessId
|
||||
|
||||
return $status
|
||||
}
|
||||
|
||||
|
||||
7
.github/workflows/no-hold-label.yml
vendored
7
.github/workflows/no-hold-label.yml
vendored
@@ -7,10 +7,13 @@ on:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
# Let each label event run to completion. Cancelling in-progress runs leaves
|
||||
# CANCELLED entries in the PR's check-suite rollup, which poisons GitHub's
|
||||
# `status:success` search filter even though all real CI passed. The job is
|
||||
# a tiny no-op github-script call, so the wasted compute is negligible.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check-hold-label:
|
||||
|
||||
4
.github/workflows/superset-e2e.yml
vendored
4
.github/workflows/superset-e2e.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
matrix:
|
||||
parallel_id: [0, 1, 2, 3, 4, 5]
|
||||
browser: ["chrome"]
|
||||
app_root: ["", "/app/prefix"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: ["chromium"]
|
||||
app_root: ["", "/app/prefix"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
|
||||
87
.github/workflows/superset-translations-comment.yml
vendored
Normal file
87
.github/workflows/superset-translations-comment.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Translation Regression Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Translations"]
|
||||
types: [completed]
|
||||
|
||||
# This workflow posts a PR comment when the Translations workflow detects a
|
||||
# regression. It uses the workflow_run trigger so that it always runs in the
|
||||
# base-branch context and can safely be granted write permissions, even for
|
||||
# PRs from forks.
|
||||
#
|
||||
# IMPORTANT: This workflow must NEVER check out code from the PR branch.
|
||||
# All data comes from the artifact uploaded by the Translations workflow.
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-comment:
|
||||
runs-on: ubuntu-24.04
|
||||
# Only act when the Translations workflow failed (which means a regression
|
||||
# was detected — the workflow exits 1 on regression).
|
||||
if: github.event.workflow_run.conclusion == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Download regression artifact
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: translation-regression
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: /tmp/translation-regression
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const prNumberFile = '/tmp/translation-regression/pr-number.txt';
|
||||
const reportFile = '/tmp/translation-regression/regression-report.md';
|
||||
|
||||
if (!fs.existsSync(prNumberFile) || !fs.existsSync(reportFile)) {
|
||||
console.log('Artifact files not found, skipping comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = parseInt(fs.readFileSync(prNumberFile, 'utf8').trim(), 10);
|
||||
if (!prNumber) {
|
||||
console.log('Could not parse PR number, skipping comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const report = fs.readFileSync(reportFile, 'utf8');
|
||||
const marker = '<!-- translation-regression-bot -->';
|
||||
const body = `${marker}\n${report}`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
console.log(`Updated existing comment ${existing.id} on PR #${prNumber}`);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
console.log(`Created new comment on PR #${prNumber}`);
|
||||
}
|
||||
90
.github/workflows/superset-translations.yml
vendored
90
.github/workflows/superset-translations.yml
vendored
@@ -20,6 +20,9 @@ concurrency:
|
||||
jobs:
|
||||
frontend-check-translations:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -51,12 +54,16 @@ jobs:
|
||||
|
||||
babel-extract:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
@@ -64,12 +71,85 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Python
|
||||
if: steps.check.outputs.python
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
uses: ./.github/actions/setup-backend/
|
||||
|
||||
- name: Install msgcat
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install gettext tools
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y gettext
|
||||
|
||||
- name: Test babel extraction
|
||||
if: steps.check.outputs.python
|
||||
# Fetch the base ref so we can compare PR-introduced regressions
|
||||
# against a fair baseline (also runs babel_update against the base
|
||||
# source) — this isolates the PR's contribution from any pre-existing
|
||||
# drift on the base branch.
|
||||
- name: Fetch base ref and create comparison worktree
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: |
|
||||
# For PRs use the base branch; for direct pushes compare against the previous commit.
|
||||
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||
if [ -n "$BASE_REF" ]; then
|
||||
git fetch --depth=1 origin "$BASE_REF"
|
||||
else
|
||||
git fetch --depth=2 origin "${{ github.ref }}"
|
||||
fi
|
||||
git worktree add /tmp/base-worktree FETCH_HEAD
|
||||
|
||||
# Run babel_update against BASE source + BASE translations. Any drift
|
||||
# already present on the base branch (source strings that have changed
|
||||
# without .po updates) shows up here as fuzzies — and will also show
|
||||
# up in the PR run, so it cancels out in the comparison.
|
||||
- name: Baseline — run babel_update against BASE source
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
working-directory: /tmp/base-worktree
|
||||
run: ./scripts/translations/babel_update.sh
|
||||
|
||||
- name: Record baseline translation counts
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: |
|
||||
python scripts/translations/check_translation_regression.py \
|
||||
--count \
|
||||
--translations-dir /tmp/base-worktree/superset/translations \
|
||||
> /tmp/before.json
|
||||
|
||||
# Reset the PR worktree's translations to the pristine BASE state so
|
||||
# both babel_update runs start from the same .po files. The only
|
||||
# difference between the runs is the source code.
|
||||
- name: Reset PR worktree translations to pristine BASE
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: git checkout FETCH_HEAD -- superset/translations/
|
||||
|
||||
- name: Run babel_update against PR source
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: ./scripts/translations/babel_update.sh
|
||||
|
||||
- name: Check for translation regression
|
||||
id: regression
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python scripts/translations/check_translation_regression.py \
|
||||
--compare /tmp/before.json \
|
||||
--report /tmp/regression-report.md
|
||||
|
||||
# Save the PR number so the comment workflow can post the report without
|
||||
# needing write permissions on this pull_request-triggered job.
|
||||
- name: Save PR number for comment workflow
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.regression.outcome == 'failure'
|
||||
run: echo "${{ github.event.pull_request.number }}" > /tmp/pr-number.txt
|
||||
|
||||
- name: Upload regression artifact
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.regression.outcome == 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: translation-regression
|
||||
path: |
|
||||
/tmp/regression-report.md
|
||||
/tmp/pr-number.txt
|
||||
|
||||
- name: Fail if regression detected
|
||||
if: steps.regression.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -115,6 +115,8 @@ release.json
|
||||
superset/translations/**/messages.json
|
||||
# these mo binary files are generated by `pybabel compile`
|
||||
superset/translations/**/messages.mo
|
||||
# cross-language index generated by scripts/translations/build_translation_index.py
|
||||
superset/translations/translation_index.json
|
||||
|
||||
docker/requirements-local.txt
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ repos:
|
||||
hooks:
|
||||
- id: check-docstring-first
|
||||
- id: check-added-large-files
|
||||
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$
|
||||
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$|^superset/translations/.*\.po$
|
||||
- id: check-yaml
|
||||
exclude: ^helm/superset/templates/
|
||||
- id: debug-statements
|
||||
|
||||
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=all
|
||||
enable=json-import,disallowed-sql-import,consider-using-transaction
|
||||
enable=disallowed-sql-import,consider-using-transaction
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
@@ -202,6 +202,8 @@ RUN mkdir -p /app/data && chown -R superset:superset /app/data
|
||||
|
||||
# Copy compiled things from previous stages
|
||||
COPY --from=superset-node /app/superset/static/assets superset/static/assets
|
||||
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
|
||||
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
|
||||
|
||||
# TODO, when the next version comes out, use --exclude superset/translations
|
||||
COPY superset superset
|
||||
|
||||
@@ -88,7 +88,6 @@ using our `docker compose` constructs to support production-type use-cases. For
|
||||
environments, we recommend using [minikube](https://minikube.sigs.k8s.io/docs/start/) along
|
||||
our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
|
||||
documentation.
|
||||
configured to be secure.
|
||||
:::
|
||||
|
||||
### Supported environment variables
|
||||
|
||||
@@ -335,6 +335,92 @@ npm run build-translation
|
||||
pybabel compile -d superset/translations
|
||||
```
|
||||
|
||||
### Backfilling missing translations with AI
|
||||
|
||||
For languages with many untranslated strings, the repo includes a script that
|
||||
uses Claude AI to generate draft translations for any missing entries. All
|
||||
AI-generated strings are marked `#, fuzzy` and tagged with an attribution
|
||||
comment so that human reviewers know they need to be checked before merging.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
```bash
|
||||
pip install -r superset/translations/requirements.txt
|
||||
```
|
||||
|
||||
Claude Code must be installed and authenticated (`claude --version` should
|
||||
work). The script calls `claude -p` internally — no separate API key is needed.
|
||||
|
||||
#### Step 1 — Build the translation index
|
||||
|
||||
The index captures every already-translated string in every language and
|
||||
serves as cross-language context for the AI. Rebuild it whenever `.po` files
|
||||
change significantly:
|
||||
|
||||
```bash
|
||||
python scripts/translations/build_translation_index.py
|
||||
# Writes: superset/translations/translation_index.json
|
||||
```
|
||||
|
||||
#### Step 2 — Preview with a dry run
|
||||
|
||||
Check what would be translated without writing anything:
|
||||
|
||||
```bash
|
||||
python scripts/translations/backfill_po.py --lang fr --limit 20 --dry-run
|
||||
```
|
||||
|
||||
Output shows each string, its translation, and a context tag:
|
||||
- No tag — 3+ reference languages available (high confidence)
|
||||
- `[ctx:N]` — only N other languages have this string (lower confidence)
|
||||
- `[ctx:0]` — no other language has this string yet; English alone used
|
||||
|
||||
#### Step 3 — Run the backfill
|
||||
|
||||
```bash
|
||||
python scripts/translations/backfill_po.py --lang fr
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--lang LANG` | required | ISO language code (`fr`, `de`, `ja`, …) |
|
||||
| `--batch-size N` | 50 | Strings per Claude request |
|
||||
| `--limit N` | unlimited | Stop after N entries |
|
||||
| `--min-context N` | 0 | Skip entries with fewer than N reference translations |
|
||||
| `--model MODEL` | `claude-sonnet-4-6` | Claude model to use |
|
||||
| `--dry-run` | off | Print without writing |
|
||||
| `--no-fuzzy` | off | Don't mark entries as fuzzy |
|
||||
|
||||
Use `--min-context 2` to skip strings that have fewer than 2 reference
|
||||
translations in other languages. Those strings are more likely to be ambiguous
|
||||
(short labels, UI fragments) where the correct meaning can't be inferred
|
||||
without additional context.
|
||||
|
||||
#### Step 4 — Review and commit
|
||||
|
||||
Open the target `.po` file and search for `fuzzy`. For each generated entry:
|
||||
|
||||
1. Verify the translation is correct for the UI context.
|
||||
2. Remove the `# Machine-translated via backfill_po.py` comment and the
|
||||
`#, fuzzy` flag line once you are satisfied.
|
||||
3. If the translation is wrong, correct the `msgstr` before removing the flag.
|
||||
4. Commit the `.po` file — do **not** commit `translation_index.json` (it is
|
||||
gitignored and regenerated locally).
|
||||
|
||||
#### Running via npm
|
||||
|
||||
From `superset-frontend/`:
|
||||
|
||||
```bash
|
||||
# Rebuild index
|
||||
npm run translations:build-index
|
||||
|
||||
# Backfill (pass arguments after --)
|
||||
npm run translations:backfill -- --lang fr --dry-run
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
### Python
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.0"
|
||||
"webpack": "^5.107.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -12270,14 +12270,7 @@ pvutils@^1.1.3, pvutils@^1.1.5:
|
||||
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.5.tgz#84b0dea4a5d670249aa9800511804ee0b7c2809c"
|
||||
integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==
|
||||
|
||||
qs@^6.12.3:
|
||||
version "6.14.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c"
|
||||
integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==
|
||||
dependencies:
|
||||
side-channel "^1.1.0"
|
||||
|
||||
qs@~6.15.1:
|
||||
qs@^6.12.3, qs@~6.15.1:
|
||||
version "6.15.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9"
|
||||
integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
|
||||
@@ -14964,10 +14957,10 @@ webpack-virtual-modules@^0.6.2:
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.107.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.0.tgz#9e0d8d8baf24e76f058103f4f06ac6bb528b645a"
|
||||
integrity sha512-PSxeHk/dmLYZlnTU+vL1Gej6Evg5RNtl3flhxBresfznFnzxinHMzHKloHnywM/3ouQv7/AlZCswWDIkNSggUA==
|
||||
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
|
||||
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.15.5 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -111,9 +111,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| init.resources | object | `{}` | |
|
||||
| init.tolerations | list | `[]` | |
|
||||
| init.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to init job |
|
||||
| initImage.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| initImage.repository | string | `"apache/superset"` | |
|
||||
| initImage.tag | string | `"dockerize"` | |
|
||||
| nameOverride | string | `nil` | Provide a name to override the name of the chart |
|
||||
| nodeSelector | object | `{}` | |
|
||||
| postgresql | object | see `values.yaml` | Configuration values for the postgresql dependency. ref: https://github.com/bitnami/charts/tree/main/bitnami/postgresql |
|
||||
|
||||
@@ -194,11 +194,6 @@ image:
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
initImage:
|
||||
repository: apache/superset
|
||||
tag: dockerize
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8088
|
||||
@@ -303,15 +298,28 @@ supersetNode:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# bash's /dev/tcp redirect performs a TCP connect; no external
|
||||
# `dockerize`, `nc`, or busybox needed. SECONDS-based deadline
|
||||
# mirrors the prior `dockerize -timeout 120s` behaviour.
|
||||
SECONDS=0
|
||||
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -407,15 +415,31 @@ supersetWorker:
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -495,15 +519,31 @@ supersetCeleryBeat:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -594,15 +634,31 @@ supersetCeleryFlower:
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -764,15 +820,26 @@ init:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
|
||||
@@ -58,7 +58,7 @@ dependencies = [
|
||||
"flask-wtf>=1.1.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet>=3.0.3, <=3.5.0",
|
||||
"gunicorn>=22.0.0; sys_platform != 'win32'",
|
||||
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.45, <1",
|
||||
@@ -66,7 +66,7 @@ dependencies = [
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.6.1, <2",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.0",
|
||||
"markdown>=3.10.2",
|
||||
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
|
||||
"marshmallow>=3.0, <4",
|
||||
"marshmallow-union>=0.1",
|
||||
@@ -101,7 +101,7 @@ dependencies = [
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
"sqlglot>=30.8.0, <31",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.9.0, <1.0",
|
||||
"typing-extensions>=4, <5",
|
||||
@@ -137,7 +137,7 @@ databricks = [
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
@@ -220,6 +220,7 @@ development = [
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
"polib", # used by scripts/translations/ and their unit tests
|
||||
"pre-commit",
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
|
||||
@@ -166,7 +166,7 @@ greenlet==3.1.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==23.0.0
|
||||
gunicorn==25.3.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
@@ -213,7 +213,7 @@ mako==1.3.11
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
markdown==3.8.1
|
||||
markdown==3.10.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
@@ -415,7 +415,7 @@ sqlalchemy-utils==0.42.0
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==28.10.0
|
||||
sqlglot==30.8.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -388,7 +388,7 @@ grpcio==1.71.0
|
||||
# grpcio-status
|
||||
grpcio-status==1.60.1
|
||||
# via google-api-core
|
||||
gunicorn==23.0.0
|
||||
gunicorn==25.3.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -511,7 +511,7 @@ mako==1.3.11
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
markdown==3.8.1
|
||||
markdown==3.10.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -677,6 +677,8 @@ ply==3.11
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# jsonpath-ng
|
||||
polib==1.2.0
|
||||
# via apache-superset
|
||||
polyline==2.0.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -985,7 +987,7 @@ sqlalchemy-utils==0.42.0
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==28.10.0
|
||||
sqlglot==30.8.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
653
scripts/translations/backfill_po.py
Normal file
653
scripts/translations/backfill_po.py
Normal file
@@ -0,0 +1,653 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Backfill missing translations in a .po file using Claude AI.
|
||||
|
||||
For each untranslated (empty msgstr) entry in the target language, the script
|
||||
sends the English source string along with all available translations in other
|
||||
languages to Claude as context, then writes the AI-generated translation back
|
||||
into the .po file marked as #, fuzzy for human review.
|
||||
|
||||
Usage:
|
||||
# Build the translation index first (one-time or when .po files change)
|
||||
python scripts/translations/build_translation_index.py
|
||||
|
||||
# Backfill French translations
|
||||
python scripts/translations/backfill_po.py --lang fr
|
||||
|
||||
# Dry run (print what would be translated without writing)
|
||||
python scripts/translations/backfill_po.py --lang de --dry-run
|
||||
|
||||
# Limit to 100 entries and use a specific model
|
||||
python scripts/translations/backfill_po.py --lang es --limit 100 \
|
||||
--model claude-opus-4-6
|
||||
|
||||
Options:
|
||||
--lang LANG ISO language code to backfill (required)
|
||||
--batch-size N Number of strings per Claude request (default: 50)
|
||||
--limit N Stop after translating N entries (default: unlimited)
|
||||
--min-context N Skip entries with fewer than N existing translations across
|
||||
reference languages (default: 0 — translate everything)
|
||||
--model MODEL Claude model ID (default: claude-sonnet-4-6)
|
||||
--index PATH Path to translation_index.json (default: auto-detect)
|
||||
--dry-run Print translations without writing to .po file
|
||||
--no-fuzzy Do not mark generated translations as fuzzy (default: mark fuzzy)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import polib # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
print("polib is required. Run: pip install polib", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
|
||||
DEFAULT_INDEX = TRANSLATIONS_DIR / "translation_index.json"
|
||||
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||
DEFAULT_BATCH_SIZE = 50
|
||||
|
||||
# Language names for the prompt, keyed by ISO code
|
||||
LANGUAGE_NAMES: dict[str, str] = {
|
||||
"ar": "Arabic",
|
||||
"ca": "Catalan",
|
||||
"de": "German",
|
||||
"es": "Spanish",
|
||||
"fa": "Persian (Farsi)",
|
||||
"fr": "French",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"mi": "Māori",
|
||||
"nl": "Dutch",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"pt_BR": "Brazilian Portuguese",
|
||||
"ru": "Russian",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"tr": "Turkish",
|
||||
"uk": "Ukrainian",
|
||||
"zh": "Chinese (Simplified)",
|
||||
"zh_TW": "Chinese (Traditional)",
|
||||
}
|
||||
|
||||
|
||||
def _lang_name(code: str) -> str:
|
||||
"""Return a human-readable language name for an ISO language code."""
|
||||
return LANGUAGE_NAMES.get(code, code)
|
||||
|
||||
|
||||
def _plural_key(msgid: str, msgid_plural: str) -> str:
|
||||
"""Build the translation index key used for pluralized entries."""
|
||||
return f"{msgid}\x00{msgid_plural}"
|
||||
|
||||
|
||||
def _is_missing(entry: polib.POEntry) -> bool:
|
||||
"""Return True for entries that need a translation."""
|
||||
if entry.obsolete:
|
||||
return False
|
||||
if entry.msgid_plural:
|
||||
return not any(v for v in entry.msgstr_plural.values())
|
||||
return not entry.msgstr
|
||||
|
||||
|
||||
def _context_langs(
|
||||
item: dict[str, Any], index: dict[str, Any], target_lang: str
|
||||
) -> list[str]:
|
||||
"""Return sorted list of language codes that have translations for this entry."""
|
||||
key = item["index_key"]
|
||||
if key not in index:
|
||||
return []
|
||||
return sorted(
|
||||
lang for lang, val in index[key].items() if lang != target_lang and val
|
||||
)
|
||||
|
||||
|
||||
def _context_count(
|
||||
item: dict[str, Any], index: dict[str, Any], target_lang: str
|
||||
) -> int:
|
||||
"""Return the number of other-language translations available for this entry."""
|
||||
return len(_context_langs(item, index, target_lang))
|
||||
|
||||
|
||||
def _render_item(
|
||||
i: int,
|
||||
item: dict[str, Any],
|
||||
index: dict[str, Any],
|
||||
target_lang: str,
|
||||
reference_langs_sorted: list[str],
|
||||
) -> list[str]:
|
||||
"""Render one batch entry as prompt lines."""
|
||||
lines: list[str] = []
|
||||
ctx = _context_count(item, index, target_lang)
|
||||
if ctx == 0:
|
||||
lines.append(
|
||||
f"--- [{i}] (no reference translations — translate conservatively) ---"
|
||||
)
|
||||
else:
|
||||
plural = "s" if ctx != 1 else ""
|
||||
lines.append(f"--- [{i}] ({ctx} reference translation{plural}) ---")
|
||||
lines.append(f"English: {json.dumps(item['msgid'], ensure_ascii=False)}")
|
||||
if item.get("msgid_plural"):
|
||||
plural_json = json.dumps(item["msgid_plural"], ensure_ascii=False)
|
||||
lines.append(f"English plural: {plural_json}")
|
||||
key = item["index_key"]
|
||||
if key in index and reference_langs_sorted:
|
||||
for lang in reference_langs_sorted:
|
||||
val = index[key].get(lang)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, dict):
|
||||
forms = "; ".join(
|
||||
f"[{k}] {json.dumps(v, ensure_ascii=False)}" for k, v in val.items()
|
||||
)
|
||||
lines.append(f"{_lang_name(lang)}: {forms}")
|
||||
else:
|
||||
lines.append(
|
||||
f"{_lang_name(lang)}: {json.dumps(val, ensure_ascii=False)}"
|
||||
)
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def build_prompt(
|
||||
target_lang: str,
|
||||
batch: list[dict[str, Any]],
|
||||
index: dict[str, Any],
|
||||
) -> str:
|
||||
"""Build the Claude prompt for a batch of entries."""
|
||||
lang_name = _lang_name(target_lang)
|
||||
|
||||
# Collect which other languages actually have translations for this batch
|
||||
reference_langs: set[str] = set()
|
||||
for item in batch:
|
||||
key = item["index_key"]
|
||||
if key in index:
|
||||
reference_langs.update(
|
||||
lang for lang, val in index[key].items() if lang != target_lang and val
|
||||
)
|
||||
reference_langs_sorted = sorted(reference_langs)
|
||||
|
||||
lines: list[str] = [
|
||||
"You are a professional translator specializing in software UI strings.",
|
||||
f"Translate the following English strings into {lang_name} ({target_lang}).",
|
||||
"",
|
||||
"Rules:",
|
||||
"- Preserve all format placeholders exactly: %(name)s, {name}, %s, %d, etc.",
|
||||
"- Preserve HTML tags if present.",
|
||||
"- Keep the same tone and register as the reference translations.",
|
||||
"- For plural forms, provide translations for all plural forms"
|
||||
" required by the language.",
|
||||
"- Return ONLY a JSON object mapping each numeric index (as a string)"
|
||||
" to its translation.",
|
||||
"- Do not add any explanation, preamble, or markdown fences.",
|
||||
"",
|
||||
"Important: Many strings are short fragments or single words that are"
|
||||
" ambiguous in English (e.g. 'Scale' could mean a measurement scale,"
|
||||
" to scale an image, or fish scales). Use the translations in other"
|
||||
" languages as your primary signal for which meaning is intended —"
|
||||
" they collectively disambiguate the intended sense. When no"
|
||||
" other-language translations are available for an entry, translate"
|
||||
" conservatively based on the most common meaning in a data"
|
||||
" visualization UI context.",
|
||||
"",
|
||||
]
|
||||
|
||||
if reference_langs_sorted:
|
||||
lines.append(
|
||||
f"Reference translations are provided per string where available "
|
||||
f"({', '.join(_lang_name(lc) for lc in reference_langs_sorted)})."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("Strings to translate:")
|
||||
lines.append("")
|
||||
|
||||
for i, item in enumerate(batch):
|
||||
lines.extend(_render_item(i, item, index, target_lang, reference_langs_sorted))
|
||||
|
||||
# Add guidance on plural form counts per language whenever ANY entry in
|
||||
# the batch is plural — batches mix singular and plural in .po order, so
|
||||
# gating on the first entry would silently drop the guidance whenever
|
||||
# the plural entries happen to land after a singular one.
|
||||
if any(item.get("msgid_plural") for item in batch):
|
||||
lines.append(
|
||||
"Note: provide ALL plural forms required by the target language "
|
||||
"(e.g. French needs 2, Russian needs 3, Arabic needs 6)."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append(
|
||||
'Expected output format: {"0": "<translation>", "1": "<translation>", ...}'
|
||||
)
|
||||
lines.append("(keys are the numeric indices of the strings above)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_response(text: str, batch_size: int) -> dict[int, str]:
|
||||
"""Parse the JSON object from Claude's response."""
|
||||
# Strip any accidental markdown fences
|
||||
text = re.sub(r"^```[^\n]*\n", "", text.strip())
|
||||
text = re.sub(r"\n```$", "", text)
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"Could not parse response as JSON: {exc}\n\nResponse:\n{text}"
|
||||
) from exc
|
||||
# _process_batches only catches ValueError/RuntimeError, so a non-object
|
||||
# response (list, scalar, null) must surface as ValueError rather than
|
||||
# bubbling up an AttributeError from .items() and aborting the whole run.
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(
|
||||
f"Expected a JSON object mapping indices to translations, "
|
||||
f"got {type(raw).__name__}.\n\nResponse:\n{text}"
|
||||
)
|
||||
# Preserve dict/list values as JSON strings so plural responses (where
|
||||
# v is a dict of plural forms) can be re-parsed downstream by
|
||||
# _apply_translation's json.loads. str(v) on a dict produces Python
|
||||
# repr ({'0': 'x'}) which is not valid JSON.
|
||||
return {
|
||||
int(k): (
|
||||
json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else str(v)
|
||||
)
|
||||
for k, v in raw.items()
|
||||
if str(k).isdigit()
|
||||
}
|
||||
|
||||
|
||||
def translate_batch(
|
||||
model: str,
|
||||
target_lang: str,
|
||||
batch: list[dict[str, Any]],
|
||||
index: dict[str, Any],
|
||||
) -> dict[int, str]:
|
||||
"""Send a batch of strings to Claude via `claude -p`.
|
||||
|
||||
Returns a dict mapping batch index to translated string.
|
||||
"""
|
||||
claude_bin = shutil.which("claude")
|
||||
if not claude_bin:
|
||||
raise RuntimeError(
|
||||
"claude CLI not found. Install Claude Code or add it to PATH."
|
||||
)
|
||||
prompt = build_prompt(target_lang, batch, index)
|
||||
# Pipe the prompt over stdin rather than passing it as argv: a single batch
|
||||
# with many reference languages can grow into the tens of KB and approach
|
||||
# ARG_MAX on some platforms.
|
||||
# claude_bin is resolved via shutil.which — not user-controlled input
|
||||
result = subprocess.run( # noqa: S603
|
||||
[claude_bin, "--model", model, "-p"],
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"claude exited with code {result.returncode}:\n{result.stderr}"
|
||||
)
|
||||
return parse_response(result.stdout.strip(), len(batch))
|
||||
|
||||
|
||||
def _apply_plural_translation(entry: polib.POEntry, translation: str) -> None:
|
||||
"""Distribute a model response across the entry's plural forms.
|
||||
|
||||
Model may return a JSON dict ({"0": "form0", "1": "form1"}), a JSON list
|
||||
(["form0", "form1"], also valid since plural forms are ordered), a JSON
|
||||
scalar (a single translation that fills every form), or a plain non-JSON
|
||||
string (older models that ignore the JSON instruction).
|
||||
"""
|
||||
try:
|
||||
plural_value = json.loads(translation)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
for k in entry.msgstr_plural:
|
||||
entry.msgstr_plural[k] = translation
|
||||
return
|
||||
|
||||
if isinstance(plural_value, dict):
|
||||
entry.msgstr_plural = {int(k): str(v) for k, v in plural_value.items()}
|
||||
return
|
||||
|
||||
if isinstance(plural_value, list) and plural_value:
|
||||
# Distribute list items across plural form indices in order; if the
|
||||
# model returned fewer forms than the language requires, repeat the
|
||||
# last form rather than leaving slots blank.
|
||||
forms = [str(v) for v in plural_value]
|
||||
for k in sorted(entry.msgstr_plural):
|
||||
entry.msgstr_plural[k] = forms[k] if k < len(forms) else forms[-1]
|
||||
return
|
||||
|
||||
# Scalar (or empty list) — broadcast to every form.
|
||||
fill = str(plural_value) if plural_value not in (None, []) else translation
|
||||
for k in entry.msgstr_plural:
|
||||
entry.msgstr_plural[k] = fill
|
||||
|
||||
|
||||
def _apply_translation(
|
||||
entry: polib.POEntry,
|
||||
translation: str,
|
||||
item: dict[str, Any],
|
||||
model: str,
|
||||
mark_fuzzy: bool,
|
||||
) -> None:
|
||||
"""Write a translation string into a POEntry and add attribution."""
|
||||
if entry.msgid_plural:
|
||||
_apply_plural_translation(entry, translation)
|
||||
else:
|
||||
entry.msgstr = translation
|
||||
|
||||
if mark_fuzzy and "fuzzy" not in entry.flags:
|
||||
entry.flags.append("fuzzy")
|
||||
|
||||
refs = item["context_langs"]
|
||||
refs_tag = f" [refs: {', '.join(refs)}]" if refs else " [no refs]"
|
||||
attribution = f"Machine-translated via backfill_po.py ({model}){refs_tag}"
|
||||
if entry.tcomment:
|
||||
if attribution not in entry.tcomment:
|
||||
entry.tcomment = f"{entry.tcomment}\n{attribution}"
|
||||
else:
|
||||
entry.tcomment = attribution
|
||||
|
||||
|
||||
def _build_batch_items(
|
||||
entries: list[polib.POEntry],
|
||||
index: dict[str, Any],
|
||||
lang: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert a list of POEntries into the dict format used by translate_batch."""
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
if entry.msgid_plural:
|
||||
item: dict[str, Any] = {
|
||||
"msgid": entry.msgid,
|
||||
"msgid_plural": entry.msgid_plural,
|
||||
"index_key": _plural_key(entry.msgid, entry.msgid_plural),
|
||||
"is_plural": True,
|
||||
}
|
||||
else:
|
||||
item = {
|
||||
"msgid": entry.msgid,
|
||||
"index_key": entry.msgid,
|
||||
"is_plural": False,
|
||||
}
|
||||
item["context_langs"] = _context_langs(item, index, lang)
|
||||
item["context_count"] = len(item["context_langs"])
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _process_batches(
|
||||
missing: list[polib.POEntry],
|
||||
index: dict[str, Any],
|
||||
lang: str,
|
||||
batch_size: int,
|
||||
model: str,
|
||||
dry_run: bool,
|
||||
mark_fuzzy: bool,
|
||||
cat: polib.POFile | None = None,
|
||||
po_path: Path | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Translate missing entries in batches. Returns (translated, failed) counts.
|
||||
|
||||
When ``cat`` and ``po_path`` are provided and ``dry_run`` is False, the
|
||||
catalog is saved to disk after each batch that produced at least one
|
||||
successful translation. This means a crash mid-run only loses the in-flight
|
||||
batch rather than every batch translated so far.
|
||||
"""
|
||||
translated_count = 0
|
||||
failed_count = 0
|
||||
for batch_start in range(0, len(missing), batch_size):
|
||||
batch_entries = missing[batch_start : batch_start + batch_size]
|
||||
batch_items = _build_batch_items(batch_entries, index, lang)
|
||||
end = min(batch_start + batch_size, len(missing))
|
||||
print(
|
||||
f" Translating entries {batch_start + 1}–{end} of {len(missing)} …",
|
||||
file=sys.stderr,
|
||||
)
|
||||
try:
|
||||
translations = translate_batch(model, lang, batch_items, index)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
print(f" ERROR in batch starting at {batch_start}: {exc}", file=sys.stderr)
|
||||
failed_count += len(batch_entries)
|
||||
continue
|
||||
batch_applied = 0
|
||||
for i, entry in enumerate(batch_entries):
|
||||
translation = translations.get(i)
|
||||
if translation is None:
|
||||
print(
|
||||
f" WARNING: no translation returned for index {i} "
|
||||
f"(msgid: {entry.msgid[:60]!r})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
if dry_run:
|
||||
ctx = batch_items[i]["context_count"]
|
||||
ctx_tag = f" [ctx:{ctx}]" if ctx < 3 else ""
|
||||
print(
|
||||
f" [{lang}]{ctx_tag} {entry.msgid[:60]!r} → {translation[:60]!r}"
|
||||
)
|
||||
else:
|
||||
_apply_translation(
|
||||
entry, translation, batch_items[i], model, mark_fuzzy
|
||||
)
|
||||
batch_applied += 1
|
||||
translated_count += 1
|
||||
if (
|
||||
not dry_run
|
||||
and batch_applied > 0
|
||||
and cat is not None
|
||||
and po_path is not None
|
||||
):
|
||||
cat.save()
|
||||
print(
|
||||
f" Saved {po_path} ({batch_applied} entry(ies) in this batch).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return translated_count, failed_count
|
||||
|
||||
|
||||
def backfill(
|
||||
lang: str,
|
||||
*,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
limit: int | None = None,
|
||||
min_context: int = 0,
|
||||
model: str = DEFAULT_MODEL,
|
||||
index_path: Path = DEFAULT_INDEX,
|
||||
dry_run: bool = False,
|
||||
mark_fuzzy: bool = True,
|
||||
) -> None:
|
||||
"""Backfill missing translations in the target language's .po file."""
|
||||
# Defense against path traversal: ``lang`` lands in a filesystem path
|
||||
# without further sanitization, so reject anything that isn't an
|
||||
# ISO 639-1/639-2 code with an optional ISO 3166 region (e.g. ``pt_BR``).
|
||||
if not re.fullmatch(r"[a-z]{2,3}(_[A-Z]{2})?", lang):
|
||||
print(
|
||||
f"Invalid language code: {lang!r} "
|
||||
"(expected ISO 639 code, optionally with _<REGION>, e.g. 'fr' or 'pt_BR')",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
po_path = TRANSLATIONS_DIR / lang / "LC_MESSAGES" / "messages.po"
|
||||
if not po_path.exists():
|
||||
print(f"No .po file found for language '{lang}': {po_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not index_path.exists():
|
||||
print(
|
||||
f"Translation index not found at {index_path}.\n"
|
||||
"Run: python scripts/translations/build_translation_index.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print("Loading translation index …", file=sys.stderr)
|
||||
with open(index_path, encoding="utf-8") as f:
|
||||
index: dict[str, Any] = json.load(f)
|
||||
|
||||
print(f"Loading {po_path} …", file=sys.stderr)
|
||||
cat = polib.pofile(str(po_path))
|
||||
|
||||
missing: list[polib.POEntry] = [e for e in cat if e.msgid and _is_missing(e)]
|
||||
print(f"Found {len(missing)} untranslated entries for '{lang}'.", file=sys.stderr)
|
||||
|
||||
if min_context > 0:
|
||||
before = len(missing)
|
||||
missing = [
|
||||
e
|
||||
for e in missing
|
||||
if _context_count(
|
||||
{
|
||||
"index_key": (
|
||||
_plural_key(e.msgid, e.msgid_plural)
|
||||
if e.msgid_plural
|
||||
else e.msgid
|
||||
)
|
||||
},
|
||||
index,
|
||||
lang,
|
||||
)
|
||||
>= min_context
|
||||
]
|
||||
skipped = before - len(missing)
|
||||
print(
|
||||
f"Skipping {skipped} entries with fewer than {min_context} reference "
|
||||
f"translation(s) (use --min-context 0 to include them).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if limit is not None:
|
||||
missing = missing[:limit]
|
||||
print(f"Limiting to {limit} entries.", file=sys.stderr)
|
||||
|
||||
if not missing:
|
||||
print("Nothing to do.", file=sys.stderr)
|
||||
return
|
||||
|
||||
translated_count, failed_count = _process_batches(
|
||||
missing,
|
||||
index,
|
||||
lang,
|
||||
batch_size,
|
||||
model,
|
||||
dry_run,
|
||||
mark_fuzzy,
|
||||
cat=cat,
|
||||
po_path=po_path,
|
||||
)
|
||||
|
||||
print(
|
||||
f"\nDone. Translated: {translated_count}, Failed/skipped: {failed_count}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not dry_run and translated_count > 0:
|
||||
print(
|
||||
f"Translations written to {po_path} (marked #, fuzzy for review).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse CLI arguments and run translation backfill."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Backfill missing .po translations using Claude AI",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lang", required=True, help="ISO language code (e.g. fr, de, ja)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=DEFAULT_BATCH_SIZE,
|
||||
help=f"Strings per Claude request (default: {DEFAULT_BATCH_SIZE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum number of entries to translate (default: unlimited)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"Claude model ID (default: {DEFAULT_MODEL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
type=Path,
|
||||
default=DEFAULT_INDEX,
|
||||
help=f"Path to translation_index.json (default: {DEFAULT_INDEX})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print translations without modifying the .po file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-context",
|
||||
type=int,
|
||||
default=0,
|
||||
metavar="N",
|
||||
help=(
|
||||
"Skip entries with fewer than N reference translations in other languages "
|
||||
"(default: 0 = translate everything). Strings with low context are more "
|
||||
"likely to be ambiguous single words or fragments — set to e.g. 2 to only "
|
||||
"translate strings that have been confirmed in at least 2 other languages."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-fuzzy",
|
||||
dest="mark_fuzzy",
|
||||
action="store_false",
|
||||
default=True,
|
||||
help=(
|
||||
"Do not mark generated translations as #, fuzzy. "
|
||||
"WARNING: fuzzy entries are excluded from compiled .mo files. "
|
||||
"Removing this flag causes AI-generated translations to be served "
|
||||
"to end users without human review — only use after you have "
|
||||
"manually verified the .po file."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
backfill(
|
||||
lang=args.lang,
|
||||
batch_size=args.batch_size,
|
||||
limit=args.limit,
|
||||
min_context=args.min_context,
|
||||
model=args.model,
|
||||
index_path=args.index,
|
||||
dry_run=args.dry_run,
|
||||
mark_fuzzy=args.mark_fuzzy,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
153
scripts/translations/build_translation_index.py
Normal file
153
scripts/translations/build_translation_index.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Build a cross-language translation index from all .po files.
|
||||
|
||||
Outputs a JSON file structured as:
|
||||
{
|
||||
"<msgid>": {
|
||||
"<lang>": "<translated string or null>",
|
||||
...
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
For plural entries the key is "<msgid>\x00<msgid_plural>" and the value
|
||||
is a dict mapping lang -> {0: "...", 1: "..."} (or null if untranslated).
|
||||
|
||||
Usage:
|
||||
python scripts/translations/build_translation_index.py
|
||||
python scripts/translations/build_translation_index.py \
|
||||
--translations-dir superset/translations \
|
||||
--output /tmp/translation_index.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import polib # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
print("polib is required. Install with: pip install polib", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
|
||||
DEFAULT_OUTPUT = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "superset"
|
||||
/ "translations"
|
||||
/ "translation_index.json"
|
||||
)
|
||||
|
||||
|
||||
def _is_translated(entry: polib.POEntry) -> bool:
|
||||
"""Return True if the entry has a non-empty, non-fuzzy translation."""
|
||||
if "fuzzy" in entry.flags:
|
||||
return False
|
||||
if entry.msgid_plural:
|
||||
return any(v for v in entry.msgstr_plural.values())
|
||||
return bool(entry.msgstr)
|
||||
|
||||
|
||||
def _plural_key(entry: polib.POEntry) -> str:
|
||||
"""Build the combined key used for plural translation entries."""
|
||||
return f"{entry.msgid}\x00{entry.msgid_plural}"
|
||||
|
||||
|
||||
def build_index(translations_dir: Path) -> dict[str, Any]:
|
||||
"""Read all .po files and build a combined translation index."""
|
||||
index: dict[str, dict[str, Any]] = {}
|
||||
|
||||
langs = sorted(
|
||||
d
|
||||
for d in os.listdir(translations_dir)
|
||||
if (translations_dir / d / "LC_MESSAGES" / "messages.po").exists()
|
||||
and d != "en" # en has empty msgstr by convention (source = target)
|
||||
)
|
||||
|
||||
for lang in langs:
|
||||
po_path = translations_dir / lang / "LC_MESSAGES" / "messages.po"
|
||||
cat = polib.pofile(str(po_path))
|
||||
for entry in cat:
|
||||
if not entry.msgid:
|
||||
continue # skip header entry
|
||||
|
||||
if entry.msgid_plural:
|
||||
key = _plural_key(entry)
|
||||
if key not in index:
|
||||
index[key] = {}
|
||||
# Fuzzy entries are unreviewed (often machine-generated drafts),
|
||||
# so excluding them prevents feeding unverified translations
|
||||
# back into the AI backfill prompt as trusted context.
|
||||
index[key][lang] = (
|
||||
dict(entry.msgstr_plural) if _is_translated(entry) else None
|
||||
)
|
||||
else:
|
||||
key = entry.msgid
|
||||
if key not in index:
|
||||
index[key] = {}
|
||||
index[key][lang] = entry.msgstr if _is_translated(entry) else None
|
||||
|
||||
# Ensure every entry has a slot for every language (null if missing)
|
||||
for key in index:
|
||||
for lang in langs:
|
||||
index[key].setdefault(lang, None)
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse arguments, build the translation index, and write it to disk."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build cross-language translation index"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--translations-dir",
|
||||
type=Path,
|
||||
default=TRANSLATIONS_DIR,
|
||||
help="Path to the translations directory (default: superset/translations)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=DEFAULT_OUTPUT,
|
||||
help=(
|
||||
"Output JSON file path"
|
||||
" (default: superset/translations/translation_index.json)"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Reading .po files from {args.translations_dir} …", file=sys.stderr)
|
||||
index = build_index(args.translations_dir)
|
||||
print(f"Indexed {len(index)} message IDs.", file=sys.stderr)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"Written to {args.output}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
250
scripts/translations/check_translation_regression.py
Executable file
250
scripts/translations/check_translation_regression.py
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""
|
||||
Check that source-code changes don't cause translation regressions.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Count non-fuzzy translated entries in all .po files and write JSON to stdout:
|
||||
|
||||
python check_translation_regression.py --count
|
||||
|
||||
Compare the current .po state against a previously-recorded baseline and fail
|
||||
if any language lost translations:
|
||||
|
||||
python check_translation_regression.py --compare /path/to/before.json
|
||||
|
||||
Optionally write a markdown report to a file (used by CI to post a PR comment):
|
||||
|
||||
python check_translation_regression.py --compare before.json --report report.md
|
||||
|
||||
Use a translations directory other than the repo default (used by CI to count
|
||||
against a separate base-branch worktree):
|
||||
|
||||
python check_translation_regression.py --count \\
|
||||
--translations-dir /tmp/base-worktree/superset/translations
|
||||
|
||||
Typical CI workflow
|
||||
-------------------
|
||||
1. Create a base-branch worktree alongside the PR worktree
|
||||
2. Run babel_update.sh in the base worktree (extract from BASE source)
|
||||
3. Record baseline: python ... --count --translations-dir BASE_TREE > before.json
|
||||
4. Run babel_update.sh in the PR worktree (extract from PR source) starting
|
||||
from the same pristine BASE translations
|
||||
5. Compare: python ... --compare before.json [--report report.md]
|
||||
|
||||
Comparing two babel_update outputs that started from the same BASE .po files
|
||||
isolates regressions caused by the PR's source diff from any pre-existing
|
||||
drift on the base branch.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_TRANSLATIONS_DIR = (
|
||||
Path(__file__).resolve().parent.parent.parent / "superset" / "translations"
|
||||
)
|
||||
|
||||
# English .po files use empty msgstr by convention (source language == target),
|
||||
# so they always show 0 translated entries and should not be checked.
|
||||
SKIP_LANGS = {"en"}
|
||||
|
||||
|
||||
def count_translated(po_file: Path) -> int:
|
||||
"""Return the number of non-fuzzy translated messages in a .po file.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: if ``msgfmt`` fails (e.g. malformed
|
||||
.po file). The regression check exists to surface translation
|
||||
problems, so a silent zero would defeat its purpose — let the
|
||||
caller see a malformed file as a hard failure.
|
||||
"""
|
||||
import shutil # noqa: PLC0415
|
||||
|
||||
msgfmt = shutil.which("msgfmt") or "msgfmt"
|
||||
result = subprocess.run( # noqa: S603
|
||||
[msgfmt, "--statistics", "-o", "/dev/null", str(po_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
# stderr: "123 translated messages, 4 fuzzy translations, 56 untranslated messages."
|
||||
match = re.search(r"(\d+) translated message", result.stderr)
|
||||
if not match:
|
||||
raise RuntimeError(
|
||||
f"Could not parse msgfmt --statistics output for {po_file}: "
|
||||
f"{result.stderr!r}"
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def get_counts(translations_dir: Path) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")):
|
||||
lang = po_file.parent.parent.name
|
||||
if lang in SKIP_LANGS:
|
||||
continue
|
||||
try:
|
||||
counts[lang] = count_translated(po_file)
|
||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
# A malformed .po file (msgfmt non-zero exit, or stderr we
|
||||
# can't parse) is a real problem worth seeing, but it shouldn't
|
||||
# take the whole regression check down with it — that would
|
||||
# hide every other language's status. Skip and warn instead;
|
||||
# the missing lang will not appear in the comparison output.
|
||||
print(
|
||||
f"WARNING: skipping {lang} — {po_file} could not be counted: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
|
||||
"""Build a markdown report for posting as a PR comment."""
|
||||
rows = "\n".join(
|
||||
f"| `{lang}` | {b} | {a} | -{b - a} |" for lang, b, a in regressions
|
||||
)
|
||||
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
|
||||
return (
|
||||
"## ⚠️ Translation Regression Detected\n\n"
|
||||
f"This PR causes existing translations to become fuzzy or be removed "
|
||||
f"in {affected}. Please fix the affected `.po` files before merging.\n\n"
|
||||
"| Language | Before | After | Lost |\n"
|
||||
"|----------|-------:|------:|-----:|\n"
|
||||
f"{rows}\n\n"
|
||||
"### How to fix\n\n"
|
||||
"**1. Install dependencies** (if not already set up):\n\n"
|
||||
"```bash\n"
|
||||
"pip install -r superset/translations/requirements.txt\n"
|
||||
"sudo apt-get install gettext # or: brew install gettext\n"
|
||||
"```\n\n"
|
||||
"**2. Re-extract strings and sync `.po` files:**\n\n"
|
||||
"```bash\n"
|
||||
"./scripts/translations/babel_update.sh\n"
|
||||
"```\n\n"
|
||||
"This rewrites `superset/translations/messages.pot` from the current "
|
||||
"source files and merges the changes into every `.po` file. Strings "
|
||||
"whose `msgid` changed will be marked `#, fuzzy`.\n\n"
|
||||
f"**3. Resolve the fuzzy entries** in the affected language files "
|
||||
f"({affected}):\n\n"
|
||||
"```bash\n"
|
||||
"grep -n '#, fuzzy' superset/translations/<lang>/LC_MESSAGES/messages.po\n"
|
||||
"```\n\n"
|
||||
"For each fuzzy entry, either rewrite the `msgstr` to match the new "
|
||||
"string and remove the `#, fuzzy` line, or clear the `msgstr` to "
|
||||
'`""` if you cannot provide a translation.\n\n'
|
||||
"**4. Commit your changes to the `.po` files.**\n"
|
||||
)
|
||||
|
||||
|
||||
def cmd_count(translations_dir: Path) -> None:
|
||||
counts = get_counts(translations_dir)
|
||||
print(json.dumps(counts, indent=2))
|
||||
|
||||
|
||||
def cmd_compare(
|
||||
before_path: str,
|
||||
translations_dir: Path,
|
||||
report_path: Optional[str] = None,
|
||||
) -> None:
|
||||
with open(before_path) as f:
|
||||
before: dict[str, int] = json.load(f)
|
||||
|
||||
after = get_counts(translations_dir)
|
||||
|
||||
regressions: list[tuple[str, int, int]] = []
|
||||
for lang, before_count in sorted(before.items()):
|
||||
after_count = after.get(lang, 0)
|
||||
if after_count < before_count:
|
||||
regressions.append((lang, before_count, after_count))
|
||||
|
||||
if regressions:
|
||||
print("Translation regression detected!\n")
|
||||
for lang, b, a in regressions:
|
||||
lost = b - a
|
||||
print(f" {lang}: {b} -> {a} (-{lost} string(s) became fuzzy or removed)")
|
||||
print(
|
||||
"\nStrings renamed or deleted by this PR invalidated existing translations."
|
||||
)
|
||||
print(
|
||||
"Update the affected .po files to restore the lost entries before merging."
|
||||
)
|
||||
if report_path:
|
||||
Path(report_path).write_text(
|
||||
build_regression_report(regressions), encoding="utf-8"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# All good — print a summary so it's easy to read in CI logs.
|
||||
print("No translation regressions.\n")
|
||||
for lang in sorted(after):
|
||||
b = before.get(lang, 0)
|
||||
a = after[lang]
|
||||
if a > b:
|
||||
delta = f"+{a - b}"
|
||||
elif a == b:
|
||||
delta = "no change"
|
||||
else:
|
||||
delta = f"-{b - a}"
|
||||
print(f" {lang}: {b} -> {a} ({delta})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check for translation regressions in .po files."
|
||||
)
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument(
|
||||
"--count",
|
||||
action="store_true",
|
||||
help="Output translation counts per language as JSON.",
|
||||
)
|
||||
action.add_argument(
|
||||
"--compare",
|
||||
metavar="BEFORE_JSON",
|
||||
help="Compare current counts against a baseline JSON file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report",
|
||||
metavar="REPORT_MD",
|
||||
help="When --compare detects regressions, write a markdown report here.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--translations-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_TRANSLATIONS_DIR,
|
||||
help=(
|
||||
"Path to the translations directory containing per-language "
|
||||
"LC_MESSAGES/messages.po files (default: <repo>/superset/translations)."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.count:
|
||||
cmd_count(args.translations_dir)
|
||||
else:
|
||||
cmd_compare(args.compare, args.translations_dir, args.report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"pydantic>=2.8.0",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
"sqlglot>=30.8.0, <31",
|
||||
"typing-extensions>=4.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
waitForChartLoad,
|
||||
ChartSpec,
|
||||
getChartAliasesBySpec,
|
||||
} from 'cypress/utils';
|
||||
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { WORLD_HEALTH_CHARTS } from './utils';
|
||||
import { isLegacyResponse } from '../../utils/vizPlugins';
|
||||
|
||||
describe('Dashboard top-level controls', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
});
|
||||
|
||||
// flaky test - query completes before assertion
|
||||
it.skip('should allow chart level refresh', () => {
|
||||
const mapSpec = WORLD_HEALTH_CHARTS.find(
|
||||
({ viz }) => viz === 'world_map',
|
||||
) as ChartSpec;
|
||||
waitForChartLoad(mapSpec).then(gridComponent => {
|
||||
const mapId = gridComponent.attr('data-test-chart-id');
|
||||
cy.get('[data-test="grid-container"]').find('.world_map').should('exist');
|
||||
cy.get(`#slice_${mapId}-controls`).click();
|
||||
cy.get(`[data-test="slice_${mapId}-menu"]`)
|
||||
.find('[data-test="refresh-chart-menu-item"]')
|
||||
.click({ force: true });
|
||||
// likely cause for flakiness:
|
||||
// The query completes before this assertion happens.
|
||||
// Solution: pause the network before clicking, assert, then unpause network.
|
||||
cy.get('[data-test="refresh-chart-menu-item"]').should(
|
||||
'have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
waitForChartLoad(mapSpec);
|
||||
cy.get('[data-test="refresh-chart-menu-item"]').should(
|
||||
'not.have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow dashboard level force refresh', () => {
|
||||
// when charts are not start loading, for example, under a secondary tab,
|
||||
// should allow force refresh
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
getChartAliasesBySpec(WORLD_HEALTH_CHARTS).then(aliases => {
|
||||
cy.get('[aria-label="ellipsis"]').click();
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
|
||||
'not.have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').click({
|
||||
force: true,
|
||||
});
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
|
||||
'have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
|
||||
// wait all charts force refreshed.
|
||||
|
||||
cy.wait(aliases).then(xhrs => {
|
||||
xhrs.forEach(async ({ response, request }) => {
|
||||
const responseBody = response?.body;
|
||||
const isCached = isLegacyResponse(responseBody)
|
||||
? responseBody.is_cached
|
||||
: responseBody.result[0].is_cached;
|
||||
// request url should indicate force-refresh operation
|
||||
expect(request.url).to.have.string('force=true');
|
||||
// is_cached in response should be false
|
||||
expect(isCached).to.equal(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
cy.get('[aria-label="ellipsis"]').click();
|
||||
cy.get('[data-test="refresh-dashboard-menu-item"]').and(
|
||||
'not.have.class',
|
||||
'ant-dropdown-menu-item-disabled',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { nativeFilters } from 'cypress/support/directories';
|
||||
|
||||
import {
|
||||
addCountryNameFilter,
|
||||
applyNativeFilterValueWithIndex,
|
||||
enterNativeFilterEditModal,
|
||||
inputNativeFilterDefaultValue,
|
||||
saveNativeFilterSettings,
|
||||
validateFilterNameOnDashboard,
|
||||
testItems,
|
||||
interceptFilterState,
|
||||
} from './utils';
|
||||
import {
|
||||
prepareDashboardFilters,
|
||||
SAMPLE_CHART,
|
||||
visitDashboard,
|
||||
} from './shared_dashboard_functions';
|
||||
|
||||
function openMoreFilters(waitFilterState = true) {
|
||||
interceptFilterState();
|
||||
// Wait for the dropdown button to appear when filters are overflowed
|
||||
// The button only appears when there are overflowed filters
|
||||
cy.getBySel('dropdown-container-btn', { timeout: 10000 })
|
||||
.should('exist')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
|
||||
if (waitFilterState) {
|
||||
cy.wait('@postFilterState');
|
||||
}
|
||||
}
|
||||
|
||||
function openVerticalFilterBar() {
|
||||
cy.getBySel('dashboard-filters-panel').should('exist');
|
||||
cy.getBySel('filter-bar__expand-button').click();
|
||||
}
|
||||
|
||||
function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') {
|
||||
cy.getBySel('filterbar-orientation-icon').click();
|
||||
cy.wait(250);
|
||||
cy.get('.filter-bar-orientation-submenu')
|
||||
.contains('Orientation of filter bar')
|
||||
.should('exist')
|
||||
.trigger('mouseover');
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
cy.get('.ant-dropdown-menu-item-selected')
|
||||
.contains('Horizontal (Top)')
|
||||
.should('exist');
|
||||
cy.get('.ant-dropdown-menu-item').contains('Vertical (Left)').click();
|
||||
cy.getBySel('dashboard-filters-panel').should('exist');
|
||||
} else {
|
||||
cy.get('.ant-dropdown-menu-item-selected')
|
||||
.contains('Vertical (Left)')
|
||||
.should('exist');
|
||||
cy.get('.ant-dropdown-menu-item').contains('Horizontal (Top)').click();
|
||||
cy.getBySel('loading-indicator').should('exist');
|
||||
cy.getBySel('filter-bar').should('exist');
|
||||
cy.getBySel('dashboard-filters-panel').should('not.exist');
|
||||
}
|
||||
}
|
||||
|
||||
describe('Horizontal FilterBar', () => {
|
||||
it('should go from vertical to horizontal and the opposite', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
setFilterBarOrientation('vertical');
|
||||
});
|
||||
|
||||
it('should show all default actions in horizontal mode', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
cy.getBySel('horizontal-filterbar-empty')
|
||||
.contains('No filters are currently added to this dashboard.')
|
||||
.should('exist');
|
||||
cy.get(nativeFilters.filtersPanel.filterGear).click({
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu').should('be.visible');
|
||||
cy.getBySel('filter-bar__create-filter').should('exist');
|
||||
cy.getBySel('filterbar-action-buttons').should('exist');
|
||||
});
|
||||
|
||||
it('should stay in horizontal mode when reloading', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
cy.reload();
|
||||
cy.getBySel('dashboard-filters-panel').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show all filters in available space on load', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
cy.get('.filter-item-wrapper').should('have.length', 3);
|
||||
});
|
||||
|
||||
it('should show "more filters" on window resizing up and down', () => {
|
||||
// Use 4 filters with unique columns to ensure overflow testing while allowing all to fit at large viewport
|
||||
prepareDashboardFilters([
|
||||
{ name: 'Country', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'Code', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'Region', column: 'region', datasetId: 2 },
|
||||
{ name: 'Year', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
|
||||
// At full width, check how many filters are visible in main bar
|
||||
cy.get('.filter-item-wrapper').then($items => {
|
||||
cy.log(`Found ${$items.length} filter items at full width`);
|
||||
});
|
||||
|
||||
// Resize to force overflow
|
||||
cy.viewport(500, 1024);
|
||||
cy.wait(500); // Allow layout to stabilize after viewport change
|
||||
|
||||
// Should have some filters visible and dropdown button present
|
||||
cy.get('.filter-item-wrapper').should('have.length.lessThan', 4);
|
||||
cy.getBySel('dropdown-container-btn').should('exist');
|
||||
|
||||
// Open more filters and verify all are accessible in the dropdown
|
||||
openMoreFilters(false);
|
||||
// Check that the dropdown content contains filters
|
||||
cy.getBySel('dropdown-content').within(() => {
|
||||
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
|
||||
});
|
||||
|
||||
// Close the dropdown
|
||||
cy.getBySel('filter-bar').click();
|
||||
|
||||
// Test with medium viewport
|
||||
cy.viewport(800, 1024);
|
||||
cy.wait(500); // Allow layout to stabilize after viewport change
|
||||
|
||||
// May or may not have overflow at this size - test adaptively
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="dropdown-container-btn"]').length > 0) {
|
||||
openMoreFilters(false);
|
||||
cy.getBySel('dropdown-content').within(() => {
|
||||
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
|
||||
});
|
||||
cy.getBySel('filter-bar').click(); // Close dropdown
|
||||
}
|
||||
});
|
||||
|
||||
// At large viewport, all filters should fit
|
||||
cy.viewport(1300, 1024);
|
||||
cy.wait(500); // Allow layout to stabilize after viewport change
|
||||
cy.get('.filter-item-wrapper').then($items => {
|
||||
cy.log(`Found ${$items.length} filter items at large width`);
|
||||
// Just verify we have some filters, don't assert exact count
|
||||
expect($items.length).to.be.greaterThan(0);
|
||||
});
|
||||
cy.getBySel('dropdown-container-btn').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show "more filters" and scroll', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_4', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_5', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_6', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_7', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_8', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_9', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_10', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_11', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_12', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
|
||||
cy.get('.filter-item-wrapper').should('have.length', 4);
|
||||
openMoreFilters();
|
||||
cy.getBySel('form-item-value').should('have.length', 12);
|
||||
cy.getBySel('filter-control-name').contains('test_3').should('be.visible');
|
||||
cy.getBySel('filter-control-name')
|
||||
.contains('test_12')
|
||||
.should('not.be.visible');
|
||||
cy.getBySel('filter-control-name').contains('test_12').scrollIntoView();
|
||||
cy.getBySel('filter-control-name').contains('test_12').should('be.visible');
|
||||
});
|
||||
|
||||
it('should display newly added filter', () => {
|
||||
visitDashboard();
|
||||
openVerticalFilterBar();
|
||||
setFilterBarOrientation('horizontal');
|
||||
|
||||
enterNativeFilterEditModal(false);
|
||||
addCountryNameFilter();
|
||||
saveNativeFilterSettings([]);
|
||||
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
|
||||
});
|
||||
|
||||
it.skip('should spot changes in "more filters" and apply their values', () => {
|
||||
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_4', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_5', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_6', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_7', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_8', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_9', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_10', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_11', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_12', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
openMoreFilters();
|
||||
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
|
||||
cy.get(nativeFilters.applyFilter).click({ force: true });
|
||||
cy.wait('@chart');
|
||||
cy.get('.ant-scroll-number.ant-badge-count').should(
|
||||
'have.attr',
|
||||
'title',
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
it.skip('should focus filter and open "more filters" programmatically', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_2', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_3', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_4', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_5', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_6', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_7', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_8', column: 'year', datasetId: 2 },
|
||||
{ name: 'test_9', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'test_10', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'test_11', column: 'region', datasetId: 2 },
|
||||
{ name: 'test_12', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
openMoreFilters();
|
||||
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
|
||||
cy.get(nativeFilters.applyFilter).click({ force: true });
|
||||
cy.getBySel('slice-header').within(() => {
|
||||
cy.get('.filter-counts').trigger('mouseover');
|
||||
});
|
||||
cy.getBySel('filter-status-popover').contains('test_9').click();
|
||||
cy.getBySel('dropdown-content').should('be.visible');
|
||||
cy.get('.ant-select-focused').should('be.visible');
|
||||
});
|
||||
|
||||
it.skip('should show tag count and one plain tag on focus and only count on blur in select ', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'test_1', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue('Albania');
|
||||
cy.get('.ant-select-selection-search-input').clear({ force: true });
|
||||
inputNativeFilterDefaultValue('Algeria', true);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.getBySel('filter-bar').within(() => {
|
||||
cy.get(nativeFilters.filterItem).contains('Albania').should('be.visible');
|
||||
cy.get(nativeFilters.filterItem).contains('+ 1 ...').should('be.visible');
|
||||
cy.get('.ant-select-selection-search-input').click();
|
||||
cy.get(nativeFilters.filterItem).contains('+ 2 ...').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import qs from 'querystringify';
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { WORLD_HEALTH_CHARTS } from './utils';
|
||||
|
||||
interface QueryString {
|
||||
native_filters_key: string;
|
||||
}
|
||||
|
||||
describe('nativefilter url param key', () => {
|
||||
// const urlParams = { param1: '123', param2: 'abc' };
|
||||
|
||||
let initialFilterKey: string;
|
||||
it('should have cachekey in nativefilter param', () => {
|
||||
// things in `before` will not retry and the `waitForChartLoad` check is
|
||||
// especially flaky and may need more retries
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
cy.wait(1000); // wait for key to be published (debounced)
|
||||
cy.location().then(loc => {
|
||||
const queryParams = qs.parse(loc.search) as QueryString;
|
||||
expect(typeof queryParams.native_filters_key).eq('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have different key when page reloads', () => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
cy.wait(1000); // wait for key to be published (debounced)
|
||||
cy.location().then(loc => {
|
||||
const queryParams = qs.parse(loc.search) as QueryString;
|
||||
expect(queryParams.native_filters_key).not.equal(initialFilterKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import { WORLD_HEALTH_CHARTS, interceptLog } from './utils';
|
||||
|
||||
describe('Dashboard load', () => {
|
||||
it('should load dashboard', () => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it('should load in edit mode', () => {
|
||||
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
|
||||
cy.getBySel('discard-changes-button').should('be.visible');
|
||||
});
|
||||
|
||||
it('should load in standalone mode', () => {
|
||||
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
|
||||
cy.get('#app-menu').should('not.exist');
|
||||
});
|
||||
|
||||
it('should load in edit/standalone mode', () => {
|
||||
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
|
||||
cy.getBySel('discard-changes-button').should('be.visible');
|
||||
cy.get('#app-menu').should('not.exist');
|
||||
});
|
||||
|
||||
// TODO flaky test. skipping to unblock CI
|
||||
it.skip('should send log data', () => {
|
||||
interceptLog();
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD);
|
||||
cy.wait('@logs', { timeout: 15000 });
|
||||
});
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import qs from 'querystring';
|
||||
import {
|
||||
dashboardView,
|
||||
nativeFilters,
|
||||
dataTestChartName,
|
||||
} from 'cypress/support/directories';
|
||||
|
||||
import {
|
||||
addCountryNameFilter,
|
||||
applyAdvancedTimeRangeFilterOnDashboard,
|
||||
applyNativeFilterValueWithIndex,
|
||||
cancelNativeFilterSettings,
|
||||
deleteNativeFilter,
|
||||
enterNativeFilterEditModal,
|
||||
fillNativeFilterForm,
|
||||
inputNativeFilterDefaultValue,
|
||||
saveNativeFilterSettings,
|
||||
undoDeleteNativeFilter,
|
||||
validateFilterContentOnDashboard,
|
||||
validateFilterNameOnDashboard,
|
||||
testItems,
|
||||
WORLD_HEALTH_CHARTS,
|
||||
} from './utils';
|
||||
import {
|
||||
prepareDashboardFilters,
|
||||
SAMPLE_CHART,
|
||||
visitDashboard,
|
||||
} from './shared_dashboard_functions';
|
||||
|
||||
// function selectFilter(index: number) {
|
||||
// cy.get("[data-test='filter-title-container'] [draggable='true']")
|
||||
// .eq(index)
|
||||
// .click();
|
||||
// }
|
||||
|
||||
// function closeFilterModal() {
|
||||
// cy.get('body').then($body => {
|
||||
// if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
|
||||
// cy.getBySel('native-filter-modal-cancel-button').click();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
describe('Native filters', () => {
|
||||
describe('Nativefilters initial state not required', () => {
|
||||
it("User can check 'Filter has default value'", () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
|
||||
});
|
||||
|
||||
it('User can add a new native filter', () => {
|
||||
prepareDashboardFilters([]);
|
||||
|
||||
let filterKey: string;
|
||||
const removeFirstChar = (search: string) =>
|
||||
search.split('').slice(1, search.length).join('');
|
||||
|
||||
cy.location().then(loc => {
|
||||
cy.url().should('contain', 'native_filters_key');
|
||||
const queryParams = qs.parse(removeFirstChar(loc.search));
|
||||
filterKey = queryParams.native_filters_key as string;
|
||||
expect(typeof filterKey).eq('string');
|
||||
});
|
||||
enterNativeFilterEditModal();
|
||||
addCountryNameFilter();
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.location().then(loc => {
|
||||
cy.url().should('contain', 'native_filters_key');
|
||||
const queryParams = qs.parse(removeFirstChar(loc.search));
|
||||
const newfilterKey = queryParams.native_filters_key;
|
||||
expect(newfilterKey).eq(filterKey);
|
||||
});
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
});
|
||||
|
||||
it('User can restore a deleted native filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_code', column: 'country_code', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
cy.get('[data-test="restore-filter-button"]')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(nativeFilters.modal.container)
|
||||
.find(nativeFilters.filtersPanel.filterName)
|
||||
.should(
|
||||
'have.attr',
|
||||
'value',
|
||||
testItems.topTenChart.filterColumnCountryCode,
|
||||
);
|
||||
});
|
||||
|
||||
it('User can create a time grain filter', () => {
|
||||
prepareDashboardFilters([]);
|
||||
enterNativeFilterEditModal();
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.timeGrain,
|
||||
testItems.filterType.timeGrain,
|
||||
testItems.datasetForNativeFilter,
|
||||
);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
applyNativeFilterValueWithIndex(0, testItems.filterTimeGrain);
|
||||
cy.get(nativeFilters.applyFilter).click();
|
||||
cy.url().then(u => {
|
||||
const ur = new URL(u);
|
||||
expect(ur.search).to.include('native_filters');
|
||||
});
|
||||
validateFilterNameOnDashboard(testItems.filterType.timeGrain);
|
||||
validateFilterContentOnDashboard(testItems.filterTimeGrain);
|
||||
});
|
||||
|
||||
it.skip('User can create a time range filter', () => {
|
||||
enterNativeFilterEditModal();
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.timeRange,
|
||||
testItems.filterType.timeRange,
|
||||
);
|
||||
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
|
||||
cy.get(dashboardView.salesDashboardSpecific.vehicleSalesFilterTimeRange)
|
||||
.should('be.visible')
|
||||
.click();
|
||||
applyAdvancedTimeRangeFilterOnDashboard('2005-12-17', '2006-12-17');
|
||||
cy.url().then(u => {
|
||||
const ur = new URL(u);
|
||||
expect(ur.search).to.include('native_filters');
|
||||
});
|
||||
validateFilterNameOnDashboard(testItems.filterType.timeRange);
|
||||
cy.get(nativeFilters.filterFromDashboardView.timeRangeFilterContent)
|
||||
.contains('2005-12-17')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
it.skip('User can create a time column filter', () => {
|
||||
enterNativeFilterEditModal();
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.timeColumn,
|
||||
testItems.filterType.timeColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
);
|
||||
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
|
||||
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
// assert that native filter is created
|
||||
validateFilterNameOnDashboard(testItems.filterType.timeColumn);
|
||||
applyNativeFilterValueWithIndex(
|
||||
0,
|
||||
testItems.topTenChart.filterColumnYear,
|
||||
);
|
||||
cy.get(nativeFilters.applyFilter).click({ force: true });
|
||||
cy.wait('@chart');
|
||||
validateFilterContentOnDashboard(testItems.topTenChart.filterColumnYear);
|
||||
});
|
||||
|
||||
describe.only('Numerical Range Filter - Display Modes', () => {
|
||||
beforeEach(() => {
|
||||
visitDashboard();
|
||||
});
|
||||
|
||||
const expandFilterConfiguration = () => {
|
||||
cy.get('.ant-collapse-header')
|
||||
.contains('Filter Configuration')
|
||||
.should('be.visible')
|
||||
.then($header => {
|
||||
cy.wrap($header)
|
||||
.closest('.ant-collapse-item')
|
||||
.invoke('hasClass', 'ant-collapse-item-active')
|
||||
.then(isExpanded => {
|
||||
if (!isExpanded) cy.wrap($header).click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('.ant-collapse-content-box').should('be.visible');
|
||||
};
|
||||
|
||||
const selectRangeTypeOption = (label: string) => {
|
||||
cy.contains('Range Type')
|
||||
.should('be.visible')
|
||||
.closest('.ant-form-item')
|
||||
.within(() => {
|
||||
cy.get('.ant-select-selector').click();
|
||||
});
|
||||
|
||||
cy.get('.ant-select-dropdown:visible')
|
||||
.contains('.ant-select-item-option', label)
|
||||
.click();
|
||||
};
|
||||
|
||||
const applyAndAssertInputs = (from: string, to: string) => {
|
||||
// Set 'from' input
|
||||
cy.get('[data-test="range-filter-from-input"]').clear();
|
||||
cy.get('[data-test="range-filter-from-input"]').type(from);
|
||||
cy.get('[data-test="range-filter-from-input"]').blur();
|
||||
|
||||
// Set 'to' input
|
||||
cy.get('[data-test="range-filter-to-input"]').clear();
|
||||
cy.get('[data-test="range-filter-to-input"]').type(to);
|
||||
cy.get('[data-test="range-filter-to-input"]').blur();
|
||||
|
||||
// Assert values without chaining after .invoke()
|
||||
cy.get('[data-test="range-filter-from-input"]')
|
||||
.invoke('val')
|
||||
.then(val => {
|
||||
expect(val).to.equal(from);
|
||||
});
|
||||
|
||||
cy.get('[data-test="range-filter-to-input"]')
|
||||
.invoke('val')
|
||||
.then(val => {
|
||||
expect(val).to.equal(to);
|
||||
});
|
||||
};
|
||||
|
||||
it('User can create a numerical range filter with "Range Inputs" display mode', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.numerical,
|
||||
testItems.filterNumericalColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
testItems.filterNumericalColumn,
|
||||
);
|
||||
|
||||
expandFilterConfiguration();
|
||||
selectRangeTypeOption('Range Inputs');
|
||||
|
||||
saveNativeFilterSettings([]);
|
||||
cy.wait(500); // allow filter to mount
|
||||
|
||||
applyAndAssertInputs('40', '70');
|
||||
});
|
||||
|
||||
it('User can change the display mode to "Slider"', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.numerical,
|
||||
testItems.filterNumericalColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
testItems.filterNumericalColumn,
|
||||
);
|
||||
|
||||
expandFilterConfiguration();
|
||||
|
||||
cy.contains('Range Type')
|
||||
.should('be.visible')
|
||||
.closest('.ant-form-item')
|
||||
.within(() => {
|
||||
cy.get('.ant-select-selector').click({ force: true });
|
||||
});
|
||||
|
||||
cy.get('.ant-select-dropdown:visible .ant-select-item-option')
|
||||
.contains(/^Slider$/)
|
||||
.click({ force: true });
|
||||
|
||||
cy.get('.ant-select-selector').should('contain.text', 'Slider');
|
||||
|
||||
saveNativeFilterSettings([]);
|
||||
|
||||
cy.get('.ant-slider', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
cy.get('[data-test="range-filter-from-input"]', {
|
||||
timeout: 5000,
|
||||
}).should('not.exist');
|
||||
cy.get('[data-test="range-filter-to-input"]', { timeout: 5000 }).should(
|
||||
'not.exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('User can change the display mode to "Slider and range input"', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
|
||||
// Re-create filter
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.numerical,
|
||||
testItems.filterNumericalColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
testItems.filterNumericalColumn,
|
||||
);
|
||||
|
||||
expandFilterConfiguration();
|
||||
selectRangeTypeOption('Slider and range input');
|
||||
|
||||
saveNativeFilterSettings([]);
|
||||
cy.wait(500);
|
||||
|
||||
applyAndAssertInputs('40', '70');
|
||||
});
|
||||
});
|
||||
|
||||
it('User can undo deleting a native filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
undoDeleteNativeFilter();
|
||||
cy.get(nativeFilters.modal.container)
|
||||
.find(nativeFilters.filtersPanel.filterName)
|
||||
.should('have.attr', 'value', testItems.topTenChart.filterColumn);
|
||||
});
|
||||
|
||||
it('User can cancel changes in native filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
cy.getBySel('filters-config-modal__name-input').type('|EDITED', {
|
||||
force: true,
|
||||
});
|
||||
cancelNativeFilterSettings();
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
cy.contains('You have removed this filter.').should('be.visible');
|
||||
});
|
||||
|
||||
it('User can create a value filter', () => {
|
||||
visitDashboard();
|
||||
enterNativeFilterEditModal(false);
|
||||
addCountryNameFilter();
|
||||
cy.get(nativeFilters.filtersPanel.filterTypeInput)
|
||||
.find(nativeFilters.filtersPanel.filterTypeItem)
|
||||
.should('have.text', testItems.filterType.value);
|
||||
saveNativeFilterSettings([]);
|
||||
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
|
||||
});
|
||||
|
||||
it('User can apply value filter with selected values', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
applyNativeFilterValueWithIndex(0, testItems.filterDefaultValue);
|
||||
cy.get(nativeFilters.applyFilter).click();
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('User can stop filtering when filter is removed', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
cy.get(nativeFilters.filterItem)
|
||||
.contains(testItems.filterDefaultValue)
|
||||
.should('be.visible');
|
||||
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
|
||||
enterNativeFilterEditModal(false);
|
||||
deleteNativeFilter();
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
nativeFilters,
|
||||
exploreView,
|
||||
dataTestChartName,
|
||||
} from 'cypress/support/directories';
|
||||
|
||||
import {
|
||||
addParentFilterWithValue,
|
||||
applyNativeFilterValueWithIndex,
|
||||
cancelNativeFilterSettings,
|
||||
checkNativeFilterTooltip,
|
||||
clickOnAddFilterInModal,
|
||||
collapseFilterOnLeftPanel,
|
||||
enterNativeFilterEditModal,
|
||||
expandFilterOnLeftPanel,
|
||||
getNativeFilterPlaceholderWithIndex,
|
||||
inputNativeFilterDefaultValue,
|
||||
saveNativeFilterSettings,
|
||||
nativeFilterTooltips,
|
||||
validateFilterContentOnDashboard,
|
||||
valueNativeFilterOptions,
|
||||
validateFilterNameOnDashboard,
|
||||
testItems,
|
||||
} from './utils';
|
||||
import {
|
||||
prepareDashboardFilters,
|
||||
SAMPLE_CHART,
|
||||
visitDashboard,
|
||||
} from './shared_dashboard_functions';
|
||||
|
||||
function selectFilter(index: number) {
|
||||
cy.get("[data-test='filter-title-container'] [role='tab']").eq(index).click();
|
||||
}
|
||||
|
||||
function closeFilterModal() {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
|
||||
cy.getBySel('native-filter-modal-cancel-button').click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('Native filters', () => {
|
||||
describe('Nativefilters tests initial state required', () => {
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
});
|
||||
|
||||
it.skip('Verify that default value is respected after revisit', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(nativeFilters.filterItem)
|
||||
.contains(testItems.filterDefaultValue)
|
||||
.should('be.visible');
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
|
||||
// reload dashboard
|
||||
cy.reload();
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('not.exist');
|
||||
});
|
||||
validateFilterContentOnDashboard(testItems.filterDefaultValue);
|
||||
});
|
||||
|
||||
it('User can create parent filters using "Values are dependent on other filters"', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
[
|
||||
testItems.topTenChart.filterColumnRegion,
|
||||
testItems.topTenChart.filterColumn,
|
||||
].forEach(it => {
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterName)
|
||||
.contains(it)
|
||||
.should('be.visible');
|
||||
});
|
||||
getNativeFilterPlaceholderWithIndex(1)
|
||||
.invoke('text')
|
||||
.should('equal', '214 options', { timeout: 20000 });
|
||||
// apply first filter value and validate 2nd filter is depden on 1st filter.
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '3 options', {
|
||||
timeout: 20000,
|
||||
});
|
||||
});
|
||||
|
||||
it('user can delete dependent filter', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
// remove year native filter to cause it disappears from parent filter input in global sales
|
||||
cy.get(nativeFilters.modal.tabsList.removeTab)
|
||||
.should('be.visible')
|
||||
.first()
|
||||
.click();
|
||||
// make sure you are seeing global sales filter which had parent filter
|
||||
cy.get(nativeFilters.modal.tabsList.filterItemsContainer)
|
||||
.children()
|
||||
.last()
|
||||
.click();
|
||||
//
|
||||
cy.wait(1000);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters').should(
|
||||
'not.exist',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('user cannot create bi-directional dependencies between filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
{ name: 'country_code', column: 'country_code' },
|
||||
{ name: 'year', column: 'year' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
|
||||
// First, make country_name dependent on region
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
|
||||
// Second, make country_code dependent on country_name
|
||||
selectFilter(2);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumn);
|
||||
|
||||
// Now select region filter and try to add dependency
|
||||
selectFilter(0);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
|
||||
// Verify that only 'year' is available as dependency for region
|
||||
// 'country_name' and 'country_code' should not be available (would create circular dependency)
|
||||
cy.get('input[aria-label^="Limit type"]').click({ force: true });
|
||||
cy.get('[role="listbox"]').should('be.visible');
|
||||
cy.get('[role="listbox"]').should('contain', 'year');
|
||||
cy.get('[role="listbox"]').should('not.contain', 'country_name');
|
||||
cy.get('[role="listbox"]').should('not.contain', 'country_code');
|
||||
cy.get('[role="listbox"]').contains('year').click();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('Dependent filter selects first item based on parent filter selection', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
|
||||
enterNativeFilterEditModal();
|
||||
|
||||
selectFilter(0);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
// cannot use saveNativeFilterSettings because there is a bug which
|
||||
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
|
||||
// to be saved when using dependent filters so,
|
||||
// you reload the window.
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.contains('Save')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
cy.reload();
|
||||
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
|
||||
// Check that dependent filter auto-selects the first item
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterContent)
|
||||
.eq(1)
|
||||
.should('contain.text', 'Bermuda');
|
||||
});
|
||||
|
||||
it('User can create filter depend on 2 other filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
{ name: 'country_code', column: 'country_code' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(2);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(exploreView.controlPanel.addFieldValue).click();
|
||||
},
|
||||
);
|
||||
// add value to the first input
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
// add value to the second input
|
||||
addParentFilterWithValue(1, testItems.topTenChart.filterColumn);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
// filters should be displayed in the left panel
|
||||
[
|
||||
testItems.topTenChart.filterColumnRegion,
|
||||
testItems.topTenChart.filterColumn,
|
||||
testItems.topTenChart.filterColumnCountryCode,
|
||||
].forEach(it => {
|
||||
validateFilterNameOnDashboard(it);
|
||||
});
|
||||
|
||||
// initially first filter shows 39 options
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '7 options');
|
||||
// initially second filter shows 409 options
|
||||
getNativeFilterPlaceholderWithIndex(1).should('have.text', '214 options');
|
||||
// verify third filter shows 409 options
|
||||
getNativeFilterPlaceholderWithIndex(2).should('have.text', '214 options');
|
||||
|
||||
// apply first filter value
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
|
||||
// verify second filter shows 409 options available still
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '214 options');
|
||||
|
||||
// verify second filter shows 69 options available still
|
||||
getNativeFilterPlaceholderWithIndex(1).should('have.text', '3 options');
|
||||
|
||||
// apply second filter value
|
||||
applyNativeFilterValueWithIndex(1, 'United States');
|
||||
|
||||
// verify number of available options for third filter - should be decreased to only one
|
||||
getNativeFilterPlaceholderWithIndex(0).should('have.text', '1 option');
|
||||
});
|
||||
|
||||
it('User can remove parent filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region' },
|
||||
{ name: 'country_name', column: 'country_name' },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
selectFilter(1);
|
||||
// Select dependent option and auto use platform for genre
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.get(nativeFilters.modal.tabsList.removeTab)
|
||||
.should('be.visible')
|
||||
.first()
|
||||
.click({
|
||||
force: true,
|
||||
});
|
||||
saveNativeFilterSettings([SAMPLE_CHART]);
|
||||
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
|
||||
cy.contains(testItems.filterDefaultValue).should('be.visible');
|
||||
cy.contains(testItems.filterOtherCountry).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nativefilters basic interactions', () => {
|
||||
before(() => {
|
||||
visitDashboard();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
closeFilterModal();
|
||||
});
|
||||
|
||||
it('User can expand / retract native filter sidebar on a dashboard', () => {
|
||||
expandFilterOnLeftPanel();
|
||||
cy.get(nativeFilters.filtersPanel.filterGear).click({
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu').should('be.visible');
|
||||
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).should(
|
||||
'be.visible',
|
||||
);
|
||||
cy.get(nativeFilters.filterFromDashboardView.expand).should(
|
||||
'not.be.visible',
|
||||
);
|
||||
collapseFilterOnLeftPanel();
|
||||
});
|
||||
|
||||
it('User can enter filter edit pop-up by clicking on native filter edit icon', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
});
|
||||
|
||||
it('User can delete a native filter', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
cy.contains('Restore filter').should('not.exist', { timeout: 10000 });
|
||||
});
|
||||
|
||||
it('User can cancel creating a new filter', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
cancelNativeFilterSettings();
|
||||
});
|
||||
|
||||
it('Verify setting options and tooltips for value filter', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.contains('Filter value is required').scrollIntoView();
|
||||
cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
|
||||
cy.wait(300);
|
||||
|
||||
cy.contains('Filter value is required').should('be.visible').click({
|
||||
force: true,
|
||||
});
|
||||
checkNativeFilterTooltip(0, nativeFilterTooltips.preFilter);
|
||||
checkNativeFilterTooltip(1, nativeFilterTooltips.defaultValue);
|
||||
cy.get(nativeFilters.modal.container).should('be.visible');
|
||||
valueNativeFilterOptions.forEach(el => {
|
||||
cy.contains(el);
|
||||
});
|
||||
cy.contains('Values are dependent on other filters').should('not.exist');
|
||||
cy.get(
|
||||
nativeFilters.filterConfigurationSections.checkedCheckbox,
|
||||
).contains('Can select multiple values');
|
||||
checkNativeFilterTooltip(2, nativeFilterTooltips.required);
|
||||
checkNativeFilterTooltip(3, nativeFilterTooltips.defaultToFirstItem);
|
||||
checkNativeFilterTooltip(4, nativeFilterTooltips.searchAllFilterOptions);
|
||||
checkNativeFilterTooltip(5, nativeFilterTooltips.inverseSelection);
|
||||
clickOnAddFilterInModal();
|
||||
cy.contains('Values are dependent on other filters').should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
parsePostForm,
|
||||
waitForChartLoad,
|
||||
getChartAliasBySpec,
|
||||
} from 'cypress/utils';
|
||||
import { TABBED_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { expandFilterOnLeftPanel } from './utils';
|
||||
|
||||
const TREEMAP = { name: 'Treemap', viz: 'treemap_v2' };
|
||||
const LINE_CHART = { name: 'Growth Rate', viz: 'echarts_timeseries_line' };
|
||||
const BOX_PLOT = { name: 'Box plot', viz: 'box_plot' };
|
||||
const BIG_NUMBER = { name: 'Number of Girls', viz: 'big_number_total' };
|
||||
const TABLE = { name: 'Names Sorted by Num in California', viz: 'table' };
|
||||
|
||||
function topLevelTabs() {
|
||||
cy.getBySel('dashboard-component-tabs')
|
||||
.first()
|
||||
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
|
||||
.as('top-level-tabs');
|
||||
}
|
||||
|
||||
function resetTabs() {
|
||||
topLevelTabs();
|
||||
cy.get('@top-level-tabs').first().click();
|
||||
waitForChartLoad(TREEMAP);
|
||||
waitForChartLoad(BIG_NUMBER);
|
||||
waitForChartLoad(TABLE);
|
||||
}
|
||||
|
||||
describe.skip('Dashboard tabs', () => {
|
||||
before(() => {
|
||||
cy.visit(TABBED_DASHBOARD);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTabs();
|
||||
});
|
||||
|
||||
it('should switch tabs', () => {
|
||||
topLevelTabs();
|
||||
|
||||
cy.get('@top-level-tabs').first().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.first()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
cy.get('@top-level-tabs')
|
||||
.last()
|
||||
.should('not.have.class', 'ant-tabs-tab-active');
|
||||
cy.get('[data-test-chart-name="Box plot"]').should('not.exist');
|
||||
cy.get('[data-test-chart-name="Trends"]').should('not.exist');
|
||||
|
||||
cy.get('@top-level-tabs').last().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.last()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
cy.get('@top-level-tabs')
|
||||
.first()
|
||||
.should('not.have.class', 'ant-tabs-tab-active');
|
||||
waitForChartLoad(BOX_PLOT);
|
||||
|
||||
cy.get('[data-test-chart-name="Box plot"]').should('exist');
|
||||
|
||||
resetTabs();
|
||||
|
||||
// click row level tab, see 1 more chart
|
||||
cy.getBySel('dashboard-component-tabs')
|
||||
.eq(2)
|
||||
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
|
||||
.as('row-level-tabs');
|
||||
|
||||
cy.get('@row-level-tabs').last().click();
|
||||
waitForChartLoad(LINE_CHART);
|
||||
cy.get('[data-test-chart-name="Trends"]').should('exist');
|
||||
cy.get('@row-level-tabs').first().click();
|
||||
});
|
||||
|
||||
it.skip('should send new queries when tab becomes visible', () => {
|
||||
// landing in first tab
|
||||
waitForChartLoad(TREEMAP);
|
||||
|
||||
getChartAliasBySpec(TREEMAP).then(treemapAlias => {
|
||||
// apply filter
|
||||
cy.get('.Select__control').first().should('be.visible').click();
|
||||
cy.get('.Select__control input[type=text]').first().focus();
|
||||
cy.focused().type('South');
|
||||
cy.get('.Select__option').contains('South Asia').click();
|
||||
cy.get('.filter button:not(:disabled)').contains('Apply').click();
|
||||
|
||||
// send new query from same tab
|
||||
cy.wait(treemapAlias).then(({ request }) => {
|
||||
const requestBody = parsePostForm(request.body);
|
||||
const requestParams = JSON.parse(requestBody.form_data as string);
|
||||
expect(requestParams.extra_filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cy.intercept('**/superset/explore_json/?*').as('legacyChartData');
|
||||
// click row level tab, send 1 more query
|
||||
cy.get('.ant-tabs-tab').contains('row tab 2').click();
|
||||
|
||||
cy.wait('@legacyChartData').then(({ request }) => {
|
||||
const requestBody = parsePostForm(request.body);
|
||||
const requestParams = JSON.parse(requestBody.form_data as string);
|
||||
expect(requestParams.extra_filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
expect(requestParams.viz_type).eq(LINE_CHART.viz);
|
||||
});
|
||||
|
||||
cy.intercept('POST', '**/api/v1/chart/data?*').as('v1ChartData');
|
||||
|
||||
// click top level tab, send 1 more query
|
||||
cy.get('.ant-tabs-tab').contains('Tab B').click();
|
||||
|
||||
cy.wait('@v1ChartData').then(({ request }) => {
|
||||
expect(request.body.queries[0].filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
});
|
||||
|
||||
getChartAliasBySpec(BOX_PLOT).then(boxPlotAlias => {
|
||||
// navigate to filter and clear filter
|
||||
cy.get('.ant-tabs-tab').contains('Tab A').click();
|
||||
cy.get('.ant-tabs-tab').contains('row tab 1').click();
|
||||
|
||||
cy.get('.Select__clear-indicator').click();
|
||||
cy.get('.filter button:not(:disabled)').contains('Apply').click();
|
||||
|
||||
// trigger 1 new query
|
||||
waitForChartLoad(TREEMAP);
|
||||
// make sure query API not requested multiple times
|
||||
cy.on('fail', err => {
|
||||
expect(err.message).to.include('timed out waiting');
|
||||
return false;
|
||||
});
|
||||
|
||||
cy.wait(boxPlotAlias, { timeout: 1000 }).then(() => {
|
||||
throw new Error('Unexpected API call.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update size when switch tab', () => {
|
||||
cy.get('@top-level-tabs').last().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.last()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
|
||||
expandFilterOnLeftPanel();
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('@top-level-tabs').first().click();
|
||||
cy.get('@top-level-tabs')
|
||||
.first()
|
||||
.should('have.class', 'ant-tabs-tab-active');
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("[data-test-viz-type='treemap_v2'] .chart-container").then(
|
||||
$chartContainer => {
|
||||
expect($chartContainer.get(0).scrollWidth).eq(
|
||||
$chartContainer.get(0).offsetWidth,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { parsePostForm, JsonObject, waitForChartLoad } from 'cypress/utils';
|
||||
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
|
||||
import { WORLD_HEALTH_CHARTS } from './utils';
|
||||
|
||||
describe('Dashboard form data', () => {
|
||||
const urlParams = { param1: '123', param2: 'abc' };
|
||||
before(() => {
|
||||
cy.visit(WORLD_HEALTH_DASHBOARD, { qs: urlParams });
|
||||
});
|
||||
|
||||
it('should apply url params to slice requests', () => {
|
||||
cy.intercept('**/api/v1/chart/data?*', request => {
|
||||
// TODO: export url params to chart data API
|
||||
request.body.queries.forEach((query: { url_params: JsonObject }) => {
|
||||
expect(query.url_params).deep.eq(urlParams);
|
||||
});
|
||||
});
|
||||
cy.intercept('**/superset/explore_json/*', request => {
|
||||
const requestParams = JSON.parse(
|
||||
parsePostForm(request.body).form_data as string,
|
||||
);
|
||||
expect(requestParams.url_params).deep.eq(urlParams);
|
||||
});
|
||||
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
describe('AdhocFilters', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('GET', '**/api/v1/datasource/table/*/column/name/values').as(
|
||||
'filterValues',
|
||||
);
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('postJson');
|
||||
cy.intercept('GET', '**/superset/explore_json/**').as('getJson');
|
||||
cy.visitChartByName('Boys'); // a table chart
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
});
|
||||
|
||||
let numScripts = 0;
|
||||
|
||||
it('Should load AceEditor scripts when needed', () => {
|
||||
cy.get('script').then(nodes => {
|
||||
numScripts = nodes.length;
|
||||
});
|
||||
|
||||
cy.get('[data-test=adhoc_filters]').within(() => {
|
||||
cy.get('.Select__control').scrollIntoView();
|
||||
cy.get('.Select__control').click();
|
||||
cy.get('input[type=text]').focus();
|
||||
cy.focused().type('name{enter}');
|
||||
cy.get("div[role='button']").first().click();
|
||||
});
|
||||
|
||||
// antd tabs do lazy loading, so we need to click on tab with ace editor
|
||||
cy.get('#filter-edit-popover').within(() => {
|
||||
cy.get('.ant-tabs-tab').contains('Custom SQL').click();
|
||||
cy.get('.ant-tabs-tab').contains('Simple').click();
|
||||
});
|
||||
|
||||
cy.get('script').then(nodes => {
|
||||
// should load new script chunks for SQL editor
|
||||
expect(nodes.length).to.greaterThan(numScripts);
|
||||
});
|
||||
});
|
||||
|
||||
it('Set simple adhoc filter', () => {
|
||||
cy.get('[aria-label="Comparator option"] .Select__control').click();
|
||||
cy.get('[data-test=adhoc-filter-simple-value] input[type=text]').focus();
|
||||
cy.focused().type('Jack{enter}', { delay: 20 });
|
||||
|
||||
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
|
||||
|
||||
cy.get(
|
||||
'[data-test=adhoc_filters] .Select__control span.option-label',
|
||||
).contains('name = Jack');
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@postJson',
|
||||
chartSelector: 'svg',
|
||||
});
|
||||
});
|
||||
|
||||
it('Set custom adhoc filter', () => {
|
||||
const filterType = 'name';
|
||||
const filterContent = "'Amy' OR name = 'Donald'";
|
||||
|
||||
cy.get('[data-test=adhoc_filters] .Select__control').scrollIntoView();
|
||||
cy.get('[data-test=adhoc_filters] .Select__control').click();
|
||||
|
||||
// remove previous input
|
||||
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
|
||||
cy.focused().type('{backspace}');
|
||||
|
||||
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
|
||||
cy.focused().type(`${filterType}{enter}`);
|
||||
|
||||
cy.wait('@filterValues');
|
||||
|
||||
// selecting a new filter should auto-open the popup,
|
||||
// so the tab should be visible by now
|
||||
cy.get('#filter-edit-popover #adhoc-filter-edit-tabs-tab-SQL').click();
|
||||
cy.get('#filter-edit-popover .ace_content').click();
|
||||
cy.get('#filter-edit-popover .ace_text-input').type(filterContent);
|
||||
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
|
||||
|
||||
// check if the filter was saved correctly
|
||||
cy.get(
|
||||
'[data-test=adhoc_filters] .Select__control span.option-label',
|
||||
).contains(`${filterType} = ${filterContent}`);
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@postJson',
|
||||
chartSelector: 'svg',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { interceptChart } from 'cypress/utils';
|
||||
|
||||
describe('AdhocMetrics', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
cy.visitChartByName('Num Births Trend');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
});
|
||||
|
||||
it('Clear metric and set simple adhoc metric', () => {
|
||||
const metric = 'sum(num_girls)';
|
||||
const metricName = 'Sum Girls';
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="remove-control-button"]')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.contains('Drop columns/metrics here or click')
|
||||
.click();
|
||||
|
||||
// Title edit for saved metrics is disabled - switch to Simple
|
||||
cy.get('[id="adhoc-metric-edit-tabs-tab-SIMPLE"]').click();
|
||||
|
||||
cy.get('[data-test="AdhocMetricEditTitle#trigger"]').click();
|
||||
cy.get('[data-test="AdhocMetricEditTitle#input"]').type(metricName);
|
||||
|
||||
cy.get('input[aria-label="Select column"]').click();
|
||||
cy.get('input[aria-label="Select column"]').type('num_girls{enter}');
|
||||
cy.get('input[aria-label="Select aggregate options"]').click();
|
||||
cy.get('input[aria-label="Select aggregate options"]').type('sum{enter}');
|
||||
|
||||
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
|
||||
|
||||
cy.get('[data-test="control-label"]').contains(metricName);
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: `${metric} AS "${metricName}"`, // SQL statement
|
||||
});
|
||||
});
|
||||
|
||||
xit('Switch from simple to custom sql', () => {
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="metric-option"]')
|
||||
.should('have.length', 1);
|
||||
|
||||
// select column "num"
|
||||
cy.get('[data-test=metrics]').find('.Select__clear-indicator').click();
|
||||
|
||||
cy.get('[data-test=metrics]').find('.Select__control').click();
|
||||
|
||||
cy.get('[data-test=metrics]').find('.Select__control input').type('num');
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('.option-label')
|
||||
.first()
|
||||
.should('have.text', 'num')
|
||||
.click();
|
||||
|
||||
// add custom SQL
|
||||
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
|
||||
cy.get('[data-test=metrics-edit-popover]').within(() => {
|
||||
cy.get('.ace_content').click();
|
||||
cy.get('.ace_text-input').type('/COUNT(DISTINCT name)', { force: true });
|
||||
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
|
||||
});
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
|
||||
const metric = 'SUM(num)/COUNT(DISTINCT name)';
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: `${metric} AS "${metric}"`,
|
||||
});
|
||||
});
|
||||
|
||||
xit('Switch from custom sql tabs to simple', () => {
|
||||
cy.get('[data-test=metrics]').within(() => {
|
||||
cy.get('.Select__dropdown-indicator').click();
|
||||
cy.get('input[type=text]').type('num_girls{enter}');
|
||||
});
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="metric-option"]')
|
||||
.should('have.length', 2);
|
||||
|
||||
cy.get('#metrics-edit-popover').within(() => {
|
||||
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
|
||||
cy.get('.ace_identifier').contains('num_girls');
|
||||
cy.get('.ace_content').click();
|
||||
cy.get('.ace_text-input').type('{selectall}{backspace}SUM(num)');
|
||||
cy.get('#adhoc-metric-edit-tabs-tab-SIMPLE').click();
|
||||
cy.get('.Select__single-value').contains(/^num$/);
|
||||
cy.get('button').contains('Save').click();
|
||||
});
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
|
||||
const metric = 'SUM(num)';
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: `${metric} AS "${metric}"`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { interceptV1ChartData } from './utils';
|
||||
|
||||
describe('Advanced analytics', () => {
|
||||
beforeEach(() => {
|
||||
interceptV1ChartData();
|
||||
cy.intercept('PUT', '**/api/v1/explore/**').as('putExplore');
|
||||
cy.intercept('GET', '**/explore/**').as('getExplore');
|
||||
});
|
||||
|
||||
it('Create custom time compare', () => {
|
||||
cy.visitChartByName('Num Births Trend');
|
||||
cy.verifySliceSuccess({ waitAlias: '@v1Data' });
|
||||
|
||||
cy.get('.ant-collapse-header')
|
||||
.contains('Advanced analytics')
|
||||
.click({ force: true });
|
||||
|
||||
cy.get('[data-test=time_compare]').find('.ant-select').click();
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('input[type=search]')
|
||||
.type('28 days{enter}');
|
||||
|
||||
cy.get('[data-test=time_compare]').find('input[type=search]').clear();
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('input[type=search]')
|
||||
.type('1 year{enter}');
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.wait('@v1Data');
|
||||
cy.wait('@putExplore');
|
||||
|
||||
cy.reload();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@v1Data',
|
||||
});
|
||||
cy.wait('@getExplore');
|
||||
cy.get('.ant-collapse-header')
|
||||
.contains('Advanced analytics')
|
||||
.click({ force: true });
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('.ant-select-selector')
|
||||
.contains('28 days');
|
||||
cy.get('[data-test=time_compare]')
|
||||
.find('.ant-select-selector')
|
||||
.contains('1 year');
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { interceptChart } from 'cypress/utils';
|
||||
|
||||
describe('Annotations', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
it('Create formula annotation y-axis goal line', () => {
|
||||
cy.visitChartByName('Num Births Trend');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
const layerLabel = 'Goal line';
|
||||
|
||||
// get by text Annotations and Layers
|
||||
cy.get('span').contains('Annotations and Layers').click();
|
||||
|
||||
cy.get('[data-test=annotation_layers]').click();
|
||||
|
||||
cy.get('[data-test="popover-content"]').within(() => {
|
||||
cy.get('[aria-label=Name]').type(layerLabel);
|
||||
cy.get('[aria-label=Formula]').type('y=1400000');
|
||||
cy.get('button').contains('OK').click();
|
||||
});
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.get('[data-test=annotation_layers]').contains(layerLabel);
|
||||
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// ***********************************************
|
||||
// Tests for links in the explore UI
|
||||
// ***********************************************
|
||||
|
||||
import rison from 'rison';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { interceptChart } from 'cypress/utils';
|
||||
import { HEALTH_POP_FORM_DATA_DEFAULTS } from './visualizations/shared.helper';
|
||||
|
||||
const apiURL = (endpoint: string, queryObject: Record<string, unknown>) =>
|
||||
`${endpoint}?q=${rison.encode(queryObject)}`;
|
||||
|
||||
describe('Test explore links', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
it('Open and close view query modal', () => {
|
||||
cy.visitChartByName('Growth Rate');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[aria-label="Menu actions trigger"]').click();
|
||||
cy.get('span').contains('View query').parent().click();
|
||||
cy.wait('@chartData').then(() => {
|
||||
cy.get('code');
|
||||
});
|
||||
cy.get('.ant-modal-content').within(() => {
|
||||
cy.get('button.ant-modal-close').first().click({ force: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('Test iframe link', () => {
|
||||
cy.visitChartByName('Growth Rate');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[aria-label="Menu actions trigger"]').click();
|
||||
cy.get('div[role="menuitem"]').within(() => {
|
||||
cy.contains('Share').parent().click();
|
||||
});
|
||||
cy.getBySel('embed-code-button').click();
|
||||
cy.get('#embed-code-popover').within(() => {
|
||||
cy.get('textarea[name=embedCode]').contains('iframe');
|
||||
});
|
||||
});
|
||||
|
||||
it('Test chart save as AND overwrite', () => {
|
||||
interceptChart({ legacy: false }).as('tableChartData');
|
||||
|
||||
const formData = {
|
||||
...HEALTH_POP_FORM_DATA_DEFAULTS,
|
||||
viz_type: 'table',
|
||||
metrics: ['sum__SP_POP_TOTL'],
|
||||
groupby: ['country_name'],
|
||||
};
|
||||
const newChartName = `Test chart [${nanoid()}]`;
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
|
||||
cy.url().then(() => {
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="saveas-radio"]').check();
|
||||
cy.get('[data-test="new-chart-name"]').type(newChartName, {
|
||||
force: true,
|
||||
});
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
|
||||
cy.visitChartByName(newChartName);
|
||||
|
||||
// Overwriting!
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="save-overwrite-radio"]').check();
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
|
||||
const query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'slice_name',
|
||||
opr: 'eq',
|
||||
value: newChartName,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
cy.deleteChartByName(newChartName, true);
|
||||
});
|
||||
});
|
||||
|
||||
it('Test chart save as and add to new dashboard', () => {
|
||||
const chartName = 'Growth Rate';
|
||||
const newChartName = `${chartName} [${nanoid()}]`;
|
||||
const dashboardTitle = `Test dashboard [${nanoid()}]`;
|
||||
|
||||
cy.visitChartByName(chartName);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="saveas-radio"]').check();
|
||||
cy.get('[data-test="new-chart-name"]').click();
|
||||
cy.get('[data-test="new-chart-name"]').clear();
|
||||
cy.get('[data-test="new-chart-name"]').type(newChartName);
|
||||
// Add a new option using the "CreatableSelect" feature
|
||||
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
|
||||
.find('input[aria-label="Select a dashboard"]')
|
||||
.type(`${dashboardTitle}`, { force: true });
|
||||
|
||||
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
let query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'dashboard_title',
|
||||
opr: 'eq',
|
||||
value: dashboardTitle,
|
||||
},
|
||||
],
|
||||
};
|
||||
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
|
||||
cy.visitChartByName(newChartName);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
|
||||
cy.get('[data-test="query-save-button"]').click();
|
||||
cy.get('[data-test="save-overwrite-radio"]').check();
|
||||
cy.get('[data-test="new-chart-name"]').click();
|
||||
cy.get('[data-test="new-chart-name"]').clear();
|
||||
cy.get('[data-test="new-chart-name"]').type(newChartName);
|
||||
// This time around, typing the same dashboard name
|
||||
// will select the existing one
|
||||
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
|
||||
.find('input[aria-label^="Select a dashboard"]')
|
||||
.type(`${dashboardTitle}{enter}`, { force: true });
|
||||
|
||||
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.get('[data-test="btn-modal-save"]').click();
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'slice_name',
|
||||
opr: 'eq',
|
||||
value: chartName,
|
||||
},
|
||||
],
|
||||
};
|
||||
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
query = {
|
||||
filters: [
|
||||
{
|
||||
col: 'dashboard_title',
|
||||
opr: 'eq',
|
||||
value: dashboardTitle,
|
||||
},
|
||||
],
|
||||
};
|
||||
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
|
||||
expect(response.body.count).equals(1);
|
||||
});
|
||||
cy.deleteDashboardByName(dashboardTitle, true);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { interceptChart } from 'cypress/utils';
|
||||
|
||||
describe('Visualization > Big Number with Trendline', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
const BIG_NUMBER_FORM_DATA = {
|
||||
datasource: '2__table',
|
||||
viz_type: 'big_number',
|
||||
slice_id: 42,
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '2000 : 2014-01-02',
|
||||
metric: 'sum__SP_POP_TOTL',
|
||||
adhoc_filters: [],
|
||||
compare_lag: '10',
|
||||
compare_suffix: 'over 10Y',
|
||||
y_axis_format: '.3s',
|
||||
show_trend_line: true,
|
||||
start_y_axis_at_zero: true,
|
||||
color_picker: {
|
||||
r: 0,
|
||||
g: 122,
|
||||
b: 135,
|
||||
a: 1,
|
||||
},
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
chartSelector: '.superset-legacy-chart-big-number',
|
||||
});
|
||||
}
|
||||
|
||||
it('should work', () => {
|
||||
verify(BIG_NUMBER_FORM_DATA);
|
||||
cy.get('.chart-container .header-line');
|
||||
cy.get('.chart-container canvas');
|
||||
});
|
||||
|
||||
it('should work without subheader', () => {
|
||||
verify({
|
||||
...BIG_NUMBER_FORM_DATA,
|
||||
compare_lag: null,
|
||||
});
|
||||
cy.get('.chart-container .header-line');
|
||||
cy.get('.chart-container .subtitle-line').should('not.exist');
|
||||
cy.get('.chart-container canvas');
|
||||
});
|
||||
|
||||
it('should not render trendline when hidden', () => {
|
||||
verify({
|
||||
...BIG_NUMBER_FORM_DATA,
|
||||
show_trend_line: false,
|
||||
});
|
||||
cy.get('[data-test="chart-container"] .header-line');
|
||||
cy.get('[data-test="chart-container"] canvas').should('not.exist');
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { interceptChart } from 'cypress/utils';
|
||||
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
|
||||
|
||||
describe('Visualization > Big Number Total', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
const BIG_NUMBER_DEFAULTS = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
viz_type: 'big_number_total',
|
||||
};
|
||||
|
||||
it('Test big number chart with adhoc metric', () => {
|
||||
const formData = { ...BIG_NUMBER_DEFAULTS, metric: NUM_METRIC };
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test big number chart with simple filter', () => {
|
||||
const filters = [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'name',
|
||||
operator: 'IN',
|
||||
comparator: ['Aaron', 'Amy', 'Andrea'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_4y6teao56zs_ebjsvwy48c',
|
||||
},
|
||||
];
|
||||
|
||||
const formData = {
|
||||
...BIG_NUMBER_DEFAULTS,
|
||||
metric: 'count',
|
||||
adhoc_filters: filters,
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
});
|
||||
|
||||
it('Test big number chart ignores groupby', () => {
|
||||
const formData = {
|
||||
...BIG_NUMBER_DEFAULTS,
|
||||
metric: NUM_METRIC,
|
||||
groupby: ['state'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.wait(['@chartData']).then(async ({ response }) => {
|
||||
cy.verifySliceContainer();
|
||||
const responseBody = response?.body;
|
||||
expect(responseBody.result[0].query).not.contains(formData.groupby[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getDatasetId } from './shared.helper';
|
||||
|
||||
describe('Visualization > Box Plot', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const getBoxPlotFormData = datasetId => ({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'box_plot',
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '1960-01-01 : now',
|
||||
metrics: ['sum__SP_POP_TOTL'],
|
||||
adhoc_filters: [],
|
||||
groupby: ['region'],
|
||||
limit: '25',
|
||||
color_scheme: 'bnbColors',
|
||||
whisker_options: 'Min/max (no outliers)',
|
||||
});
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
verify(getBoxPlotFormData(datasetId));
|
||||
cy.get('.chart-container .box_plot canvas').should('have.length', 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
verify(getBoxPlotFormData(datasetId));
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getDatasetId } from './shared.helper';
|
||||
|
||||
describe('Visualization > Bubble', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const getBubbleFormData = datasetId => ({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'bubble',
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '2011-01-01 : 2011-01-02',
|
||||
series: 'region',
|
||||
entity: 'country_name',
|
||||
x: 'sum__SP_RUR_TOTL_ZS',
|
||||
y: 'sum__SP_DYN_LE00_IN',
|
||||
size: 'sum__SP_POP_TOTL',
|
||||
max_bubble_size: '50',
|
||||
limit: 0,
|
||||
color_scheme: 'bnbColors',
|
||||
show_legend: true,
|
||||
x_axis_label: '',
|
||||
left_margin: 'auto',
|
||||
x_axis_format: '.3s',
|
||||
x_ticks_layout: 'auto',
|
||||
x_log_scale: false,
|
||||
x_axis_showminmax: false,
|
||||
y_axis_label: '',
|
||||
bottom_margin: 'auto',
|
||||
y_axis_format: '.3s',
|
||||
y_log_scale: false,
|
||||
y_axis_showminmax: false,
|
||||
});
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
|
||||
}
|
||||
|
||||
it('should work with filter', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
verify({
|
||||
...getBubbleFormData(datasetId),
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: '==',
|
||||
comparator: 'South Asia',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_b2tfg1rs8y_8kmrcyxvsqd',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('[data-test="chart-container"]').should('be.visible');
|
||||
cy.get('[data-test="chart-container"]').within(() => {
|
||||
cy.get('svg').find('.nv-point-clips circle').should('have.length', 8);
|
||||
});
|
||||
cy.get('[data-test="chart-container"]').then(nodeList => {
|
||||
// Check that all circles have same color.
|
||||
const color = nodeList[0].getAttribute('fill');
|
||||
const circles = Array.prototype.slice.call(nodeList);
|
||||
expect(circles.every(c => c.getAttribute('fill') === color)).to.equal(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes and apply the scheme', () => {
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
cy.visitChartByParams(getBubbleFormData(datasetId));
|
||||
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
cy.get('[data-test=run-query-button]').click();
|
||||
cy.get('.bubble .nv-legend .nv-legend-symbol').should(
|
||||
'have.css',
|
||||
'fill',
|
||||
'rgb(31, 168, 201)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
describe('Visualization > Compare', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const COMPARE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'compare',
|
||||
slice_id: 60,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metrics: ['count'],
|
||||
adhoc_filters: [],
|
||||
groupby: [],
|
||||
order_desc: true,
|
||||
contribution: false,
|
||||
row_limit: 50000,
|
||||
color_scheme: 'bnbColors',
|
||||
x_axis_label: 'Frequency',
|
||||
bottom_margin: 'auto',
|
||||
x_ticks_layout: 'auto',
|
||||
x_axis_format: 'smart_date',
|
||||
x_axis_showminmax: false,
|
||||
y_axis_label: 'Num',
|
||||
left_margin: 'auto',
|
||||
y_axis_showminmax: false,
|
||||
y_log_scale: false,
|
||||
y_axis_format: '.3s',
|
||||
rolling_type: 'None',
|
||||
comparison_type: 'values',
|
||||
annotation_layers: [],
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
|
||||
}
|
||||
|
||||
it('should work without groupby', () => {
|
||||
verify(COMPARE_FORM_DATA);
|
||||
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should with group by', () => {
|
||||
verify({
|
||||
...COMPARE_FORM_DATA,
|
||||
groupby: ['gender'],
|
||||
});
|
||||
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 2);
|
||||
});
|
||||
|
||||
it('should work with filter', () => {
|
||||
verify({
|
||||
...COMPARE_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'gender',
|
||||
operator: '==',
|
||||
comparator: 'boy',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes and apply the scheme', () => {
|
||||
verify(COMPARE_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
|
||||
|
||||
describe('Download Chart > Bar chart', () => {
|
||||
const VIZ_DEFAULTS = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
it('download chart with image works', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: NUM_METRIC,
|
||||
groupby: ['state'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.get('.header-with-actions .ant-dropdown-trigger').click();
|
||||
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click();
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(1) > .ant-dropdown-menu-submenu-title',
|
||||
).click();
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)',
|
||||
).click();
|
||||
|
||||
cy.verifyDownload('.jpg', {
|
||||
contains: true,
|
||||
timeout: 25000,
|
||||
interval: 600,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
describe('Visualization > Gauge', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const GAUGE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'gauge_chart',
|
||||
metric: 'count',
|
||||
adhoc_filters: [],
|
||||
slice_id: 54,
|
||||
row_limit: 10,
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work', () => {
|
||||
verify(GAUGE_FORM_DATA);
|
||||
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...GAUGE_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'country_code',
|
||||
operator: '==',
|
||||
comparator: 'USA',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
isExtra: false,
|
||||
isNew: false,
|
||||
filterOptionName: 'filter_jaemvkxd5h_ku22m3wyo',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(GAUGE_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('bnbColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
type adhocFilter = {
|
||||
expressionType: string;
|
||||
subject: string;
|
||||
operator: string;
|
||||
comparator: string;
|
||||
clause: string;
|
||||
sqlExpression: string | null;
|
||||
filterOptionName: string;
|
||||
};
|
||||
|
||||
describe('Visualization > Graph', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const GRAPH_FORM_DATA = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'graph_chart',
|
||||
slice_id: 55,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metric: 'sum__value',
|
||||
adhoc_filters: [],
|
||||
source: 'source',
|
||||
target: 'target',
|
||||
row_limit: 50000,
|
||||
show_legend: true,
|
||||
color_scheme: 'bnbColors',
|
||||
};
|
||||
|
||||
function verify(formData: {
|
||||
[name: string]: string | boolean | number | Array<adhocFilter>;
|
||||
}): void {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work with ad-hoc metric', () => {
|
||||
verify(GRAPH_FORM_DATA);
|
||||
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...GRAPH_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'source',
|
||||
operator: '==',
|
||||
comparator: 'Agriculture',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(GRAPH_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('bnbColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
describe('Visualization > Pie', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
|
||||
});
|
||||
|
||||
const PIE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'pie',
|
||||
slice_id: 55,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metric: 'sum__num',
|
||||
adhoc_filters: [],
|
||||
groupby: ['gender'],
|
||||
row_limit: 50000,
|
||||
pie_label_type: 'key',
|
||||
donut: false,
|
||||
show_legend: true,
|
||||
show_labels: true,
|
||||
labels_outside: true,
|
||||
color_scheme: 'bnbColors',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson' });
|
||||
}
|
||||
|
||||
it('should work with ad-hoc metric', () => {
|
||||
verify(PIE_FORM_DATA);
|
||||
cy.get('.chart-container .pie canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...PIE_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'gender',
|
||||
operator: '==',
|
||||
comparator: 'boy',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container .pie canvas').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(PIE_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
describe('Visualization > Pivot Table', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
|
||||
});
|
||||
|
||||
const PIVOT_TABLE_FORM_DATA = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'pivot_table_v2',
|
||||
slice_id: 61,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '100 years ago : now',
|
||||
metrics: ['sum__num'],
|
||||
adhoc_filters: [],
|
||||
groupbyRows: ['name'],
|
||||
groupbyColumns: ['state'],
|
||||
series_limit: 5000,
|
||||
aggregateFunction: 'Sum',
|
||||
rowTotals: true,
|
||||
colTotals: true,
|
||||
valueFormat: '.3s',
|
||||
combineMetric: false,
|
||||
};
|
||||
|
||||
const TEST_METRIC = {
|
||||
expressionType: 'SIMPLE',
|
||||
column: {
|
||||
id: 338,
|
||||
column_name: 'num_boys',
|
||||
expression: '',
|
||||
filterable: false,
|
||||
groupby: false,
|
||||
is_dttm: false,
|
||||
type: 'BIGINT',
|
||||
optionName: '_col_num_boys',
|
||||
},
|
||||
aggregate: 'SUM',
|
||||
hasCustomLabel: false,
|
||||
label: 'SUM(num_boys)',
|
||||
optionName: 'metric_gvpdjt0v2qf_6hkf56o012',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
}
|
||||
|
||||
it('should work with single groupby', () => {
|
||||
verify(PIVOT_TABLE_FORM_DATA);
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
});
|
||||
|
||||
it('should work with more than one groupby', () => {
|
||||
verify({
|
||||
...PIVOT_TABLE_FORM_DATA,
|
||||
groupbyRows: ['name', 'gender'],
|
||||
});
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
|
||||
});
|
||||
|
||||
it('should work with multiple metrics', () => {
|
||||
verify({
|
||||
...PIVOT_TABLE_FORM_DATA,
|
||||
metrics: ['sum__num', TEST_METRIC],
|
||||
});
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
|
||||
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
});
|
||||
|
||||
it('should work with multiple groupby and multiple metrics', () => {
|
||||
verify({
|
||||
...PIVOT_TABLE_FORM_DATA,
|
||||
groupbyRows: ['name', 'gender'],
|
||||
metrics: ['sum__num', TEST_METRIC],
|
||||
});
|
||||
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
|
||||
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
|
||||
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
describe('Visualization > Sunburst', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
|
||||
});
|
||||
|
||||
const SUNBURST_FORM_DATA = {
|
||||
datasource: '2__table',
|
||||
viz_type: 'sunburst_v2',
|
||||
slice_id: 47,
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: 'No filter',
|
||||
columns: ['region'],
|
||||
metric: 'sum__SP_POP_TOTL',
|
||||
adhoc_filters: [],
|
||||
row_limit: 50000,
|
||||
color_scheme: 'bnbColors',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData' });
|
||||
}
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work without secondary metric', () => {
|
||||
verify(SUNBURST_FORM_DATA);
|
||||
cy.get('.chart-container svg g path').should('have.length', 7);
|
||||
});
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work with secondary metric', () => {
|
||||
verify({
|
||||
...SUNBURST_FORM_DATA,
|
||||
secondary_metric: 'sum__SP_RUR_TOTL',
|
||||
});
|
||||
cy.get('.chart-container svg g path').should('have.length', 7);
|
||||
});
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work with multiple columns', () => {
|
||||
verify({
|
||||
...SUNBURST_FORM_DATA,
|
||||
columns: ['region', 'country_name'],
|
||||
});
|
||||
cy.get('.chart-container svg g path').should('have.length', 221);
|
||||
});
|
||||
|
||||
// requires the ability to render charts using SVG only for tests
|
||||
it.skip('should work with filter', () => {
|
||||
verify({
|
||||
...SUNBURST_FORM_DATA,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: 'IN',
|
||||
comparator: ['South Asia', 'North America'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_txje2ikiv6_wxmn0qwd1xo',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.chart-container svg g path').should('have.length', 2);
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(SUNBURST_FORM_DATA);
|
||||
|
||||
cy.get('#controlSections-tab-CUSTOMIZE').click();
|
||||
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
|
||||
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
|
||||
cy.focused().type('supersetColors{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -1,474 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { interceptChart } from 'cypress/utils';
|
||||
import {
|
||||
FORM_DATA_DEFAULTS,
|
||||
NUM_METRIC,
|
||||
MAX_DS,
|
||||
MAX_STATE,
|
||||
SIMPLE_FILTER,
|
||||
} from './shared.helper';
|
||||
|
||||
// Table
|
||||
describe('Visualization > Table', () => {
|
||||
beforeEach(() => {
|
||||
interceptChart({ legacy: false }).as('chartData');
|
||||
});
|
||||
|
||||
const VIZ_DEFAULTS = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
viz_type: 'table',
|
||||
row_limit: 1000,
|
||||
};
|
||||
|
||||
const PERCENT_METRIC = {
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'CAST(SUM(num_girls) AS FLOAT)/SUM(num)',
|
||||
column: null,
|
||||
aggregate: null,
|
||||
hasCustomLabel: true,
|
||||
label: 'Girls',
|
||||
optionName: 'metric_6qwzgc8bh2v_zox7hil1mzs',
|
||||
};
|
||||
|
||||
it('Use default time column', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
granularity_sqla: undefined,
|
||||
metrics: ['count'],
|
||||
});
|
||||
cy.get('[data-test=adhoc_filters]').contains('ds');
|
||||
});
|
||||
|
||||
it('Format non-numeric metrics correctly', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P3M',
|
||||
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
|
||||
});
|
||||
// when format with smart_date, time column use format by granularity
|
||||
cy.get('.chart-container td:nth-child(1)').contains('2008 Q1');
|
||||
// other column with timestamp use adaptive formatting
|
||||
cy.get('.chart-container td:nth-child(3)').contains('2008');
|
||||
cy.get('.chart-container td:nth-child(4)').contains('TX');
|
||||
});
|
||||
|
||||
it('Format with table_timestamp_format', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P3M',
|
||||
table_timestamp_format: '%Y-%m-%d %H:%M',
|
||||
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
|
||||
});
|
||||
// time column and MAX(ds) metric column both use UTC time
|
||||
cy.get('.chart-container td:nth-child(1)').contains('2008-01-01 00:00');
|
||||
cy.get('.chart-container td:nth-child(3)').contains('2008-01-01 00:00');
|
||||
cy.get('.chart-container td')
|
||||
.contains('2008-01-01 08:00')
|
||||
.should('not.exist');
|
||||
// time column should not use time granularity when timestamp format is set
|
||||
cy.get('.chart-container td').contains('2008 Q1').should('not.exist');
|
||||
// other num numeric metric column should stay as string
|
||||
cy.get('.chart-container td').contains('TX');
|
||||
});
|
||||
|
||||
it('Test table with groupby', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC, MAX_DS],
|
||||
groupby: ['name'],
|
||||
});
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: /GROUP BY.*name/i,
|
||||
chartSelector: 'table',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test table with groupby + time column', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P3M',
|
||||
metrics: [NUM_METRIC, MAX_DS],
|
||||
groupby: ['name'],
|
||||
});
|
||||
cy.wait('@chartData').then(({ response }) => {
|
||||
cy.verifySliceContainer('table');
|
||||
const records = response?.body.result[0].data;
|
||||
// should sort by first metric when no sort by metric is set
|
||||
expect(records[0][NUM_METRIC.label]).greaterThan(
|
||||
records[1][NUM_METRIC.label],
|
||||
);
|
||||
});
|
||||
|
||||
// should handle frontend sorting correctly
|
||||
cy.get('.chart-container th').contains('name').click();
|
||||
cy.get('.chart-container td:nth-child(2):eq(0)').contains('Adam');
|
||||
cy.get('.chart-container th').contains('ds').click();
|
||||
cy.get('.chart-container th').contains('ds').click();
|
||||
cy.get('.chart-container td:nth-child(1):eq(0)').contains('2008');
|
||||
});
|
||||
|
||||
it('Test table with percent metrics and groupby', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
percent_metrics: PERCENT_METRIC,
|
||||
metrics: [],
|
||||
groupby: ['name'],
|
||||
});
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby order desc', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: NUM_METRIC,
|
||||
groupby: ['name'],
|
||||
order_desc: true,
|
||||
});
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby + order by + no metric', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [],
|
||||
groupby: ['name'],
|
||||
timeseries_limit_metric: NUM_METRIC,
|
||||
order_desc: true,
|
||||
});
|
||||
// should contain only the group by column
|
||||
cy.get('.chart-container th').its('length').should('eq', 1);
|
||||
// should order correctly
|
||||
cy.get('.chart-container td:eq(0)').contains('Michael');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby and limit', () => {
|
||||
const limit = 10;
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: NUM_METRIC,
|
||||
groupby: ['name'],
|
||||
row_limit: limit,
|
||||
};
|
||||
cy.visitChartByParams(formData);
|
||||
cy.wait('@chartData').then(({ response }) => {
|
||||
cy.verifySliceContainer('table');
|
||||
expect(response?.body.result[0].data.length).to.eq(limit);
|
||||
});
|
||||
cy.get('[data-test="row-count-label"]').contains('10 rows');
|
||||
});
|
||||
|
||||
it('Test table with columns and row limit', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
// should still work when query_mode is not-set/invalid
|
||||
query_mode: undefined,
|
||||
all_columns: ['state'],
|
||||
metrics: [],
|
||||
row_limit: 100,
|
||||
});
|
||||
|
||||
// should display in raw records mode
|
||||
cy.get(
|
||||
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
|
||||
).contains('Raw records');
|
||||
cy.get('div[data-test="all_columns"]').should('be.visible');
|
||||
cy.get('div[data-test="groupby"]').should('not.exist');
|
||||
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
cy.get('[data-test="row-count-label"]').contains('100 rows');
|
||||
|
||||
// should allow switch back to aggregate mode
|
||||
cy.get('div[data-test="query_mode"] .ant-radio-button-wrapper')
|
||||
.contains('Aggregate')
|
||||
.click();
|
||||
cy.get(
|
||||
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
|
||||
).contains('Aggregate');
|
||||
cy.get('div[data-test="all_columns"]').should('not.exist');
|
||||
cy.get('div[data-test="groupby"]').should('be.visible');
|
||||
});
|
||||
|
||||
it('Test table with columns, ordering, and row limit', () => {
|
||||
const limit = 10;
|
||||
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
query_mode: 'raw',
|
||||
all_columns: ['name', 'state', 'ds', 'num'],
|
||||
metrics: [],
|
||||
row_limit: limit,
|
||||
order_by_cols: ['["num", false]'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.wait('@chartData').then(({ response }) => {
|
||||
cy.verifySliceContainer('table');
|
||||
const records = response?.body.result[0].data;
|
||||
expect(records[0].num).greaterThan(records[records.length - 1].num);
|
||||
});
|
||||
});
|
||||
|
||||
it('Test table with simple filter', () => {
|
||||
const metrics = ['count'];
|
||||
const filters = [SIMPLE_FILTER];
|
||||
|
||||
const formData = { ...VIZ_DEFAULTS, metrics, adhoc_filters: filters };
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Tests table number formatting with % in metric name', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
percent_metrics: PERCENT_METRIC,
|
||||
groupby: ['state'],
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@chartData',
|
||||
querySubstring: /GROUP BY.*state/i,
|
||||
chartSelector: 'table',
|
||||
});
|
||||
cy.get('td').contains(/\d*%/);
|
||||
});
|
||||
|
||||
it('Test row limit with server pagination toggle', () => {
|
||||
const serverPaginationSelector =
|
||||
'[data-test="server_pagination-header"] div.pull-left [type="checkbox"]';
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
});
|
||||
|
||||
// Enable server pagination
|
||||
cy.get(serverPaginationSelector).click();
|
||||
|
||||
// Click row limit control and select high value (200k)
|
||||
cy.get('div[aria-label="Row limit"]').click();
|
||||
|
||||
// Type 200000 and press enter to select the option
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('200000{enter}');
|
||||
|
||||
// Verify that there is no error tooltip when server pagination is enabled
|
||||
cy.get('[data-test="error-tooltip"]').should('not.exist');
|
||||
|
||||
// Disable server pagination
|
||||
cy.get(serverPaginationSelector).click();
|
||||
|
||||
// Verify error tooltip appears
|
||||
cy.get('[data-test="error-tooltip"]').should('be.visible');
|
||||
|
||||
// Trigger mouseover and verify tooltip text
|
||||
cy.get('[data-test="error-tooltip"]').trigger('mouseover');
|
||||
|
||||
// Verify tooltip content
|
||||
cy.get('.ant-tooltip-inner').should('be.visible');
|
||||
cy.get('.ant-tooltip-inner').should(
|
||||
'contain',
|
||||
'Server pagination needs to be enabled for values over',
|
||||
);
|
||||
|
||||
// Hide the tooltip by adding display:none style
|
||||
cy.get('.ant-tooltip').invoke('attr', 'style', 'display: none');
|
||||
|
||||
// Enable server pagination again
|
||||
cy.get(serverPaginationSelector).click();
|
||||
|
||||
cy.get('[data-test="error-tooltip"]').should('not.exist');
|
||||
|
||||
cy.get('div[aria-label="Row limit"]').click();
|
||||
|
||||
// Type 1000000
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('1000000');
|
||||
|
||||
// Wait for 1 second
|
||||
cy.wait(1000);
|
||||
|
||||
// Press enter
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('{enter}');
|
||||
|
||||
// Wait for error tooltip to appear and verify its content
|
||||
cy.get('[data-test="error-tooltip"]')
|
||||
.should('be.visible')
|
||||
.trigger('mouseover');
|
||||
|
||||
// Wait for tooltip content and verify
|
||||
cy.get('.ant-tooltip-inner').should('exist');
|
||||
cy.get('.ant-tooltip-inner').should('be.visible');
|
||||
|
||||
// Verify tooltip content separately
|
||||
cy.get('.ant-tooltip-inner').should('contain', 'Value cannot exceed');
|
||||
});
|
||||
|
||||
it('Test sorting with server pagination enabled', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
groupby: ['name'],
|
||||
row_limit: 100000,
|
||||
server_pagination: true, // Enable server pagination
|
||||
});
|
||||
|
||||
// Wait for the initial data load
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get the first column header (name)
|
||||
cy.get('.chart-container th').contains('name').as('nameHeader');
|
||||
|
||||
// Click to sort ascending
|
||||
cy.get('@nameHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Verify first row starts with 'A'
|
||||
cy.get('.chart-container td:first').invoke('text').should('match', /^[Aa]/);
|
||||
|
||||
// Click again to sort descending
|
||||
cy.get('@nameHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Verify first row starts with 'Z'
|
||||
cy.get('.chart-container td:first').invoke('text').should('match', /^[Zz]/);
|
||||
|
||||
// Test numeric sorting
|
||||
cy.get('.chart-container th').contains('COUNT').as('countHeader');
|
||||
|
||||
// Click to sort ascending by count
|
||||
cy.get('@countHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get first two count values and verify ascending order
|
||||
cy.get('.chart-container td:nth-child(2)').then($cells => {
|
||||
const first = parseFloat($cells[0].textContent || '0');
|
||||
const second = parseFloat($cells[1].textContent || '0');
|
||||
expect(first).to.be.at.most(second);
|
||||
});
|
||||
|
||||
// Click again to sort descending
|
||||
cy.get('@countHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get first two count values and verify descending order
|
||||
cy.get('.chart-container td:nth-child(2)').then($cells => {
|
||||
const first = parseFloat($cells[0].textContent || '0');
|
||||
const second = parseFloat($cells[1].textContent || '0');
|
||||
expect(first).to.be.at.least(second);
|
||||
});
|
||||
});
|
||||
|
||||
it('Test search with server pagination enabled', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
groupby: ['name', 'state'],
|
||||
row_limit: 100000,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
});
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
const searchInputSelector = '.dt-global-filter input';
|
||||
|
||||
// Basic search test
|
||||
cy.get(searchInputSelector).should('be.visible');
|
||||
|
||||
cy.get(searchInputSelector).type('John');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/John/i);
|
||||
});
|
||||
|
||||
// Clear and test case-insensitive search
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get(searchInputSelector).type('mary');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/Mary/i);
|
||||
});
|
||||
|
||||
// Test special characters
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.get(searchInputSelector).type('Nicole');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/Nicole/i);
|
||||
});
|
||||
|
||||
// Test no results
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.get(searchInputSelector).type('XYZ123');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container').contains('No records found');
|
||||
|
||||
// Test column-specific search
|
||||
cy.get('.search-select').should('be.visible');
|
||||
|
||||
cy.get('.search-select').click();
|
||||
|
||||
cy.get('.ant-select-dropdown').should('be.visible');
|
||||
|
||||
cy.get('.ant-select-item-option').contains('state').should('be.visible');
|
||||
|
||||
cy.get('.ant-select-item-option').contains('state').click();
|
||||
|
||||
cy.get(searchInputSelector).clear();
|
||||
|
||||
cy.get(searchInputSelector).type('CA');
|
||||
|
||||
cy.wait('@chartData');
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('td[aria-labelledby="header-state"]').should('be.visible');
|
||||
|
||||
cy.get('td[aria-labelledby="header-state"]')
|
||||
.first()
|
||||
.should('contain', 'CA');
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
|
||||
|
||||
describe('Visualization > Time TableViz', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const VIZ_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'time_table' };
|
||||
|
||||
it('Test time series table multiple metrics last year total', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC, 'count'],
|
||||
column_collection: [
|
||||
{
|
||||
key: '9g4K-B-YL',
|
||||
label: 'Last Year',
|
||||
colType: 'time',
|
||||
timeLag: '1',
|
||||
comparisonType: 'value',
|
||||
},
|
||||
],
|
||||
url: '',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@getJson',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
cy.get('[data-test="time-table"]').within(() => {
|
||||
cy.get('span').contains('Sum(num)');
|
||||
cy.get('span').contains('COUNT(*)');
|
||||
});
|
||||
});
|
||||
|
||||
it('Test time series table metric and group by last year total', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC],
|
||||
groupby: ['gender'],
|
||||
column_collection: [
|
||||
{
|
||||
key: '9g4K-B-YL',
|
||||
label: 'Last Year',
|
||||
colType: 'time',
|
||||
timeLag: '1',
|
||||
comparisonType: 'value',
|
||||
},
|
||||
],
|
||||
url: '',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@getJson',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
cy.get('[data-test="time-table"]').within(() => {
|
||||
cy.get('td').contains('boy');
|
||||
cy.get('td').contains('girl');
|
||||
});
|
||||
});
|
||||
|
||||
it('Test time series various time columns', () => {
|
||||
const formData = {
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [NUM_METRIC, 'count'],
|
||||
column_collection: [
|
||||
{ key: 'LHHNPhamU', label: 'Current', colType: 'time', timeLag: 0 },
|
||||
{
|
||||
key: '9g4K-B-YL',
|
||||
label: 'Last Year',
|
||||
colType: 'time',
|
||||
timeLag: '1',
|
||||
comparisonType: 'value',
|
||||
},
|
||||
{
|
||||
key: 'JVZXtNu7_',
|
||||
label: 'YoY',
|
||||
colType: 'time',
|
||||
timeLag: 1,
|
||||
comparisonType: 'perc',
|
||||
d3format: '%',
|
||||
},
|
||||
{ key: 'tN5Gba36u', label: 'Trend', colType: 'spark' },
|
||||
],
|
||||
url: '',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@getJson',
|
||||
querySubstring: NUM_METRIC.label,
|
||||
});
|
||||
cy.get('[data-test="time-table"]').within(() => {
|
||||
cy.get('th').contains('Current');
|
||||
cy.get('th').contains('Last Year');
|
||||
cy.get('th').contains('YoY');
|
||||
cy.get('th').contains('Trend');
|
||||
|
||||
cy.get('span').contains('%');
|
||||
cy.get('svg')
|
||||
.first()
|
||||
.then(charts => {
|
||||
const firstChart = charts[0];
|
||||
expect(firstChart.clientWidth).greaterThan(0);
|
||||
expect(firstChart.clientHeight).greaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
describe('Visualization > World Map', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
|
||||
});
|
||||
|
||||
const WORLD_MAP_FORM_DATA = {
|
||||
datasource: '2__table',
|
||||
viz_type: 'world_map',
|
||||
slice_id: 45,
|
||||
granularity_sqla: 'year',
|
||||
time_grain_sqla: 'P1D',
|
||||
time_range: '2014-01-01 : 2014-01-02',
|
||||
entity: 'country_code',
|
||||
country_fieldtype: 'cca3',
|
||||
metric: 'sum__SP_RUR_TOTL_ZS',
|
||||
adhoc_filters: [],
|
||||
row_limit: 50000,
|
||||
show_bubbles: true,
|
||||
secondary_metric: 'sum__SP_POP_TOTL',
|
||||
max_bubble_size: '25',
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
cy.visitChartByParams(formData);
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
|
||||
}
|
||||
|
||||
it('should work with ad-hoc metric', () => {
|
||||
verify(WORLD_MAP_FORM_DATA);
|
||||
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 206);
|
||||
});
|
||||
|
||||
it('should work with simple filter', () => {
|
||||
verify({
|
||||
...WORLD_MAP_FORM_DATA,
|
||||
metric: 'count',
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: '==',
|
||||
comparator: 'South Asia',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_8aqxcf5co1a_x7lm2d1fq0l',
|
||||
},
|
||||
],
|
||||
});
|
||||
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 8);
|
||||
});
|
||||
|
||||
it('should hide bubbles when told so', () => {
|
||||
verify({
|
||||
...WORLD_MAP_FORM_DATA,
|
||||
show_bubbles: false,
|
||||
});
|
||||
cy.get('.slice_container').then(containers => {
|
||||
expect(
|
||||
containers[0].querySelectorAll('.bubbles circle.datamaps-bubble')
|
||||
.length,
|
||||
).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow type to search color schemes', () => {
|
||||
verify(WORLD_MAP_FORM_DATA);
|
||||
|
||||
cy.get('.Control[data-test="linear_color_scheme"]').scrollIntoView();
|
||||
cy.get(
|
||||
'.Control[data-test="linear_color_scheme"] input[type="search"]',
|
||||
).focus();
|
||||
cy.focused().type('greens{enter}');
|
||||
cy.get(
|
||||
'.Control[data-test="linear_color_scheme"] .ant-select-selection-item [data-test="greens"]',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@
|
||||
// oxlint versions (not actually enforced). Documented here for future
|
||||
// maintainers — if/when oxlint adds them, re-enable in the relevant
|
||||
// plugin section above.
|
||||
// import: newline-after-import, no-extraneous-dependencies,
|
||||
// import: no-extraneous-dependencies,
|
||||
// no-import-module-exports, no-relative-packages,
|
||||
// no-unresolved, no-useless-path-segments
|
||||
// react: default-props-match-prop-types, destructuring-assignment,
|
||||
@@ -47,7 +47,6 @@
|
||||
// forbid-prop-types, function-component-definition,
|
||||
// jsx-no-bind, jsx-uses-vars, no-access-state-in-setstate,
|
||||
// no-deprecated, no-did-update-set-state, no-typos,
|
||||
// no-unstable-nested-components,
|
||||
// no-unused-class-component-methods, no-unused-prop-types,
|
||||
// no-unused-state, prefer-stateless-function, prop-types,
|
||||
// require-default-props, sort-comp, static-property-placement
|
||||
@@ -137,6 +136,7 @@
|
||||
"import/no-self-import": "error",
|
||||
"import/no-cycle": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"import/newline-after-import": "error",
|
||||
|
||||
// === React plugin rules ===
|
||||
"react/jsx-filename-extension": [
|
||||
@@ -184,6 +184,10 @@
|
||||
"error",
|
||||
{ "button": true, "submit": true, "reset": false }
|
||||
],
|
||||
// TODO: Graduate to "error" after cleanup pass — ~150 violations
|
||||
// across the codebase require hoisting nested component definitions
|
||||
// out of their parent render functions.
|
||||
"react/no-unstable-nested-components": "warn",
|
||||
|
||||
// === React Hooks rules ===
|
||||
// TODO: Fix conditional hook usage and anonymous component issues
|
||||
@@ -271,7 +275,10 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["plugins/plugin-chart-table/src/TableChart.tsx", "plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"],
|
||||
"files": [
|
||||
"plugins/plugin-chart-table/src/TableChart.tsx",
|
||||
"plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"jsx-a11y/no-redundant-roles": "off"
|
||||
}
|
||||
|
||||
104
superset-frontend/package-lock.json
generated
104
superset-frontend/package-lock.json
generated
@@ -31,7 +31,7 @@
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.1",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -121,7 +121,7 @@
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.6.1",
|
||||
"react-arborist": "^3.7.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.9.0",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -229,7 +229,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -284,14 +284,14 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.10",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
@@ -3958,9 +3958,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@googleapis/sheets": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
|
||||
"integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==",
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.2.tgz",
|
||||
"integrity": "sha512-b1tBlMcfvNEziM4DZCikLOc9iqSlgCK1e5bMKtNQIADRXr1CQmbkHV3ZBVvTsFsjLErgihqO58Itn/kzCnSZ0A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"googleapis-common": "^8.0.0"
|
||||
@@ -12568,9 +12568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.9.0.tgz",
|
||||
"integrity": "sha512-h57mL/TsOrhimvHs6KQQLZO1T+D7FQyx+7WS17p9vV228qxmZatF0IgEXMyERWthm1QL7fAB6cEMBCtujSVbyw==",
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.10.0.tgz",
|
||||
"integrity": "sha512-uhPq0oJHk2/W2Hn6vLaNmbUUgNPPj0FINHISxfs9hqS2Hpv/TVzQFsnbxul1FJEa+YQe1Qebou2esDphwzIuKg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -13164,22 +13164,13 @@
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint-scope": {
|
||||
"version": "3.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
|
||||
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint": "*",
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -17218,9 +17209,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
|
||||
"version": "2.10.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
|
||||
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -21827,14 +21818,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -24541,9 +24532,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/geostyler": {
|
||||
"version": "18.5.1",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.1.tgz",
|
||||
"integrity": "sha512-5+vLuDo1oR4QQTnrfkccIQSe3qEn0ytV9dLiFFhnxhPdziv/Wp3vKNhJZ37MUF5yIj2ISWZ+q/VmSNH6ifvWpg==",
|
||||
"version": "18.6.0",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.6.0.tgz",
|
||||
"integrity": "sha512-q8x5V4yJlTFOIe5LSvhEHd62MrMJq1YXWJVTeAG2TUMgOudjrcglXDqKtFYtEdWHeORH6TXz7q+m6cg3RlZqAg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
@@ -32537,9 +32528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
|
||||
"integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -40280,9 +40271,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-arborist": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.6.1.tgz",
|
||||
"integrity": "sha512-h2/sPz6PXL79h7mOWjCA6Y5WNUKmA0kL8Uh6RYZQbYk7UOFBd86Jeoga4RjHMBYpOWpBPYrOJOE3HbIPUETp8w==",
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.7.0.tgz",
|
||||
"integrity": "sha512-gh2SoO0eXQVSP6zxXMGqFeXF+l2uabDGBVn0+RKqy/s7mrG5xGnfM5mhyB67cMVobC3vWYLqe6HGh7ZEZadW/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-dnd": "^14.0.3",
|
||||
@@ -44459,9 +44450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -45223,9 +45214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.4.10",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
|
||||
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
|
||||
"version": "29.4.11",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz",
|
||||
"integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -47366,13 +47357,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.106.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz",
|
||||
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
|
||||
"version": "5.107.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.1.tgz",
|
||||
"integrity": "sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@webassemblyjs/ast": "^1.14.1",
|
||||
@@ -47382,20 +47372,20 @@
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.20.0",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"enhanced-resolve": "^5.21.4",
|
||||
"es-module-lexer": "^2.1.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"loader-runner": "^4.3.1",
|
||||
"loader-runner": "^4.3.2",
|
||||
"mime-db": "^1.54.0",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.17",
|
||||
"terser-webpack-plugin": "^5.5.0",
|
||||
"watchpack": "^2.5.1",
|
||||
"webpack-sources": "^3.3.4"
|
||||
"webpack-sources": "^3.4.1"
|
||||
},
|
||||
"bin": {
|
||||
"webpack": "bin/webpack.js"
|
||||
@@ -48910,7 +48900,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -50095,7 +50085,7 @@
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"build-instrumented": "cross-env NODE_ENV=production BABEL_ENV=instrumented webpack --mode=production --color",
|
||||
"build-storybook": "storybook build",
|
||||
"build-translation": "scripts/po2json.sh",
|
||||
"translations:build-index": "python3 ../scripts/translations/build_translation_index.py",
|
||||
"translations:backfill": "python3 ../scripts/translations/backfill_po.py",
|
||||
"bundle-stats": "cross-env BUNDLE_ANALYZER=true npm run build && npx open-cli ../superset/static/stats/statistics.html",
|
||||
"clear-npm": "mkdir -p /tmp/empty && rsync -a --delete /tmp/empty/ node_modules/ && rmdir node_modules /tmp/empty",
|
||||
"core:cover": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage --coverageThreshold='{\"global\":{\"statements\":100,\"branches\":100,\"functions\":100,\"lines\":100}}' --collectCoverageFrom='[\"packages/**/src/**/*.{js,ts}\", \"!packages/superset-core/**/*\"]' packages",
|
||||
@@ -112,7 +114,7 @@
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.1",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -177,7 +179,7 @@
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -202,7 +204,7 @@
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.6.1",
|
||||
"react-arborist": "^3.7.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -276,7 +278,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.9.0",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -310,7 +312,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -365,14 +367,14 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.10",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -48,6 +48,7 @@ import NoResultsComponent from './NoResultsComponent';
|
||||
import { isMatrixifyEnabled } from '../types/matrixify';
|
||||
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
|
||||
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
|
||||
|
||||
export type WrapperProps = Dimension & {
|
||||
|
||||
@@ -897,6 +897,476 @@ test('fires onChange when pasting a selection', async () => {
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('replaces cached options with search results instead of merging', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Search Match');
|
||||
});
|
||||
|
||||
test('shows all options when filterOption is false', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Base ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = Array.from({ length: 5 }, (_, i) => ({
|
||||
label: `Server ${i}`,
|
||||
value: 100 + i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) =>
|
||||
search === ''
|
||||
? { data: page0Data, totalCount: 100 }
|
||||
: { data: searchData, totalCount: 5 },
|
||||
);
|
||||
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
filterOption={false}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('zzz_no_match');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(5);
|
||||
expect(options[0]).toHaveTextContent('Server 0');
|
||||
});
|
||||
|
||||
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: [], totalCount: 0 };
|
||||
});
|
||||
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('newval');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('newval');
|
||||
// Stale page-0 options must not bleed through.
|
||||
expect(screen.queryByText('Option 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restores base options when search is cleared', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Search Match');
|
||||
|
||||
// type() clears the input before typing, so passing '' clears the search.
|
||||
await type('');
|
||||
await waitFor(async () => {
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
});
|
||||
expect(options[0]).toHaveTextContent('Option 0');
|
||||
expect(screen.queryByText('Search Match')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('replaces results when switching between two searches', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return {
|
||||
data: [{ label: `Match-${search}`, value: `v-${search}` }],
|
||||
totalCount: 1,
|
||||
};
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
|
||||
await type('beta');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('refetches a dropped search response when the same search is repeated', async () => {
|
||||
type OptionRow = { label: string; value: string | number };
|
||||
type PageResponse = { data: OptionRow[]; totalCount: number };
|
||||
// Resolves the in-flight loadOptions promise of the calling test.
|
||||
let resolveAlpha: ((value: PageResponse) => void) | null = null;
|
||||
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
|
||||
|
||||
const loadOptions = jest.fn((search: string) => {
|
||||
if (search === '') {
|
||||
return Promise.resolve<PageResponse>({
|
||||
data: page0Data,
|
||||
totalCount: 100,
|
||||
});
|
||||
}
|
||||
if (search === 'alpha') {
|
||||
// First call: hold the promise so it resolves only after beta returns.
|
||||
// Second call (after beta): resolve immediately so the cache MUST allow
|
||||
// a refetch.
|
||||
if (!resolveAlpha) {
|
||||
return new Promise<PageResponse>(resolve => {
|
||||
resolveAlpha = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve<PageResponse>({ data: alphaData, totalCount: 1 });
|
||||
}
|
||||
return Promise.resolve<PageResponse>({ data: betaData, totalCount: 1 });
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10));
|
||||
// alpha's promise is held; switch to beta which resolves first.
|
||||
await type('beta');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
|
||||
// Release the stale alpha response. It must be dropped — its key must not
|
||||
// be cached, or returning to "alpha" later would short-circuit the fetch.
|
||||
resolveAlpha!({ data: alphaData, totalCount: 1 });
|
||||
await waitFor(async () => {
|
||||
// Beta is still showing because alpha's response was dropped.
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
|
||||
// Returning to "alpha" must re-trigger the fetch (cache wasn't poisoned).
|
||||
const callsBeforeAlphaReturn = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
await type('alpha');
|
||||
await waitFor(() => {
|
||||
const callsAfter = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBeforeAlphaReturn);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps loading indicator while a newer request is in flight after a stale response is dropped', async () => {
|
||||
// Regression for the P2 race: the `.finally` block that clears isLoading
|
||||
// must not fire when a stale (dropped) response resolves while a newer
|
||||
// request is still in flight. Otherwise the spinner disappears mid-search
|
||||
// and the undebounced scroll-pagination handler can fire against stale
|
||||
// totalCount before page 0 of the active search lands.
|
||||
type OptionRow = { label: string; value: string | number };
|
||||
type PageResponse = { data: OptionRow[]; totalCount: number };
|
||||
// Initialized to no-op so the finally block can always call them, even if
|
||||
// an assertion in the try throws before the corresponding mock ran.
|
||||
let resolveAlpha: (value: PageResponse) => void = () => {};
|
||||
let resolveBeta: (value: PageResponse) => void = () => {};
|
||||
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
|
||||
|
||||
const loadOptions = jest.fn((search: string) => {
|
||||
if (search === '') {
|
||||
return Promise.resolve<PageResponse>({
|
||||
data: page0Data,
|
||||
totalCount: 100,
|
||||
});
|
||||
}
|
||||
if (search === 'alpha') {
|
||||
return new Promise<PageResponse>(resolve => {
|
||||
resolveAlpha = resolve;
|
||||
});
|
||||
}
|
||||
return new Promise<PageResponse>(resolve => {
|
||||
resolveBeta = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const isSpinnerVisible = (): boolean =>
|
||||
Boolean(document.querySelector('.ant-select-arrow .ant-spin'));
|
||||
|
||||
try {
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
// Type 'alpha' — alpha fetch is held, loading should be true.
|
||||
await type('alpha');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10),
|
||||
);
|
||||
await waitFor(() => expect(isSpinnerVisible()).toBe(true));
|
||||
|
||||
// Type 'beta' — beta fetch is also held; both are in flight.
|
||||
await type('beta');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('beta', 0, 10),
|
||||
);
|
||||
expect(isSpinnerVisible()).toBe(true);
|
||||
|
||||
// Release the stale alpha response. It is dropped at the early-return
|
||||
// (search !== inputValueRef.current), but the in-flight counter is still
|
||||
// non-zero because beta is pending — spinner must stay visible.
|
||||
resolveAlpha({ data: alphaData, totalCount: 1 });
|
||||
// Yield a microtask so alpha's .then/.finally runs, then re-assert.
|
||||
await Promise.resolve();
|
||||
expect(isSpinnerVisible()).toBe(true);
|
||||
|
||||
// Release beta. Now the in-flight counter drops to 0 and the spinner
|
||||
// clears.
|
||||
resolveBeta({ data: betaData, totalCount: 1 });
|
||||
await waitFor(() => expect(isSpinnerVisible()).toBe(false));
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
} finally {
|
||||
// Defensive: never leave a held promise that could hang a parallel worker
|
||||
// if an assertion above threw. Promise resolve is idempotent.
|
||||
resolveAlpha({ data: alphaData, totalCount: 1 });
|
||||
resolveBeta({ data: betaData, totalCount: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
test('re-shows search results when the same search term is repeated after a clear', async () => {
|
||||
// Regression: a prior fix cached search responses' totalCount in
|
||||
// fetchedQueries. After restore-on-clear had replaced selectOptions with
|
||||
// the base list, re-typing a previously-resolved term would hit the cache
|
||||
// short-circuit and leave selectOptions stale (empty / base-only).
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
// totalCount > data.length so allValuesLoaded stays false and the
|
||||
// search path is not bypassed by the "all loaded" short-circuit.
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: alphaData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
|
||||
await type('');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const callsBefore = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
await type('alpha');
|
||||
await waitFor(() => {
|
||||
const callsAfter = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
});
|
||||
|
||||
test('appends page>1 results during an active search and discards them when search changes', async () => {
|
||||
// Covers the production branch `else { mergeData(data) }` in fetchPage that
|
||||
// fires when search is non-empty AND page > 0 — i.e. user scrolled within
|
||||
// a multi-page search result. Switching to a new search must replace, not
|
||||
// retain, the prior search's accumulated pages.
|
||||
type OptionRow = { label: string; value: string | number };
|
||||
const pageSize = 5;
|
||||
const aliceData: OptionRow[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
label: `Alice-${i}`,
|
||||
value: `a${i}`,
|
||||
}));
|
||||
const alicePage1: OptionRow[] = Array.from({ length: 3 }, (_, i) => ({
|
||||
label: `Alice-${i + 5}`,
|
||||
value: `a${i + 5}`,
|
||||
}));
|
||||
const bobData: OptionRow[] = [{ label: 'Bob-0', value: 'b0' }];
|
||||
|
||||
const loadOptions = jest.fn(
|
||||
async (
|
||||
search: string,
|
||||
page: number,
|
||||
): Promise<{
|
||||
data: OptionRow[];
|
||||
totalCount: number;
|
||||
}> => {
|
||||
if (search === '') {
|
||||
return { data: [], totalCount: 100 };
|
||||
}
|
||||
if (search === 'alice') {
|
||||
if (page === 0) return { data: aliceData, totalCount: 8 };
|
||||
return { data: alicePage1, totalCount: 8 };
|
||||
}
|
||||
return { data: bobData, totalCount: 1 };
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} pageSize={pageSize} options={loadOptions} />,
|
||||
);
|
||||
await open();
|
||||
|
||||
await type('alice');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('alice', 0, pageSize),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(5);
|
||||
});
|
||||
// Wait for loading to finish so handlePagination's `!isLoading` gate is
|
||||
// open before we fire scroll.
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('.ant-select-arrow .ant-spin')).toBeNull(),
|
||||
);
|
||||
|
||||
// Trigger pagination by dispatching a scroll event on the virtual-list
|
||||
// scroll container. jsdom returns 0 for layout properties by default, so
|
||||
// override the relevant ones before firing scroll. rc-virtual-list reads
|
||||
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
|
||||
// then forwards to onPopupScroll (handlePagination here).
|
||||
const holder = document.querySelector(
|
||||
'.rc-virtual-list-holder',
|
||||
) as HTMLElement | null;
|
||||
if (!holder) throw new Error('virtual-list holder not rendered');
|
||||
Object.defineProperty(holder, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => 1000,
|
||||
});
|
||||
Object.defineProperty(holder, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => 200,
|
||||
});
|
||||
Object.defineProperty(holder, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 200,
|
||||
});
|
||||
Object.defineProperty(holder, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => 900,
|
||||
set: () => {},
|
||||
});
|
||||
fireEvent.scroll(holder);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('alice', 1, pageSize),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
// Page 0 (5) + page 1 (3) merged
|
||||
expect(options).toHaveLength(8);
|
||||
});
|
||||
|
||||
// Switching to a new search must replace the accumulated pages, not retain
|
||||
// them.
|
||||
await type('bob');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith('bob', 0, pageSize),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Bob-0');
|
||||
});
|
||||
expect(screen.queryByText('Alice-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Alice-7')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not duplicate options when using numeric values', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
|
||||
@@ -160,6 +160,13 @@ const AsyncSelect = forwardRef(
|
||||
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
|
||||
const selectValueRef = useRef(selectValue);
|
||||
const fetchedQueries = useRef(new Map<string, number>());
|
||||
const initialOptionsRef = useRef<SelectOptionsType>(EMPTY_OPTIONS);
|
||||
const inputValueRef = useRef('');
|
||||
// Counts fetches whose `.finally` has not yet run. Loading is cleared only
|
||||
// when this drops to 0, so a stale response (which returns early without
|
||||
// updating selectOptions) cannot flip the spinner off while a newer
|
||||
// request is still pending.
|
||||
const inFlightFetchesRef = useRef(0);
|
||||
const mappedMode = isSingleMode ? undefined : 'multiple';
|
||||
const allowFetch = !fetchOnlyOnSearch || inputValue;
|
||||
const [maxTagCount, setMaxTagCount] = useState(
|
||||
@@ -183,6 +190,10 @@ const AsyncSelect = forwardRef(
|
||||
selectValueRef.current = selectValue;
|
||||
}, [selectValue]);
|
||||
|
||||
useEffect(() => {
|
||||
inputValueRef.current = inputValue;
|
||||
}, [inputValue]);
|
||||
|
||||
const sortSelectedFirst = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
sortSelectedFirstHelper(a, b, selectValueRef.current),
|
||||
@@ -333,22 +344,78 @@ const AsyncSelect = forwardRef(
|
||||
setIsLoading(true);
|
||||
|
||||
const fetchOptions = options as SelectOptionsPagePromise;
|
||||
inFlightFetchesRef.current += 1;
|
||||
fetchOptions(search, page, pageSize)
|
||||
.then(({ data, totalCount }: SelectOptionsTypePage) => {
|
||||
const mergedData = mergeData(data);
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
setTotalCount(totalCount);
|
||||
if (
|
||||
!fetchOnlyOnSearch &&
|
||||
search === '' &&
|
||||
mergedData.length >= totalCount
|
||||
) {
|
||||
setAllValuesLoaded(true);
|
||||
// Drop responses whose search arg no longer matches the user's
|
||||
// current input — otherwise a slow base fetch can land after a
|
||||
// search fetch (or a stale debounced search after a clear) and
|
||||
// re-pollute the dropdown via mergeData / search-replace. Search
|
||||
// responses are never cached in fetchedQueries: the cache stores
|
||||
// only totalCount, so a cache hit would short-circuit the fetch
|
||||
// and leave selectOptions stale (e.g. after restore-on-clear).
|
||||
// Re-issuing the search is cheap and correct.
|
||||
const matchesCurrentSearch = inputValueRef.current === search;
|
||||
if (search && !matchesCurrentSearch) {
|
||||
return;
|
||||
}
|
||||
if (!search) {
|
||||
// Accumulate base pages in a ref independent of selectOptions
|
||||
// (during an active search, selectOptions holds search results
|
||||
// and is not a safe accumulator). The accumulator is kept up
|
||||
// to date even when this response landed during a search, so
|
||||
// restore-on-clear has a complete snapshot. We don't sort here
|
||||
// — restore-on-clear sorts a copy at consumption time, and the
|
||||
// live selectOptions path below goes through mergeData which
|
||||
// sorts there. Sorting here too would double the per-page sort
|
||||
// cost on large cached option sets.
|
||||
const dataValues = new Set(data.map(opt => opt.value));
|
||||
const accumulated = initialOptionsRef.current
|
||||
.filter(opt => !dataValues.has(opt.value))
|
||||
.concat(data);
|
||||
initialOptionsRef.current = accumulated;
|
||||
if (!fetchOnlyOnSearch && accumulated.length >= totalCount) {
|
||||
setAllValuesLoaded(true);
|
||||
}
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
if (matchesCurrentSearch) {
|
||||
// No active search — push to live selectOptions and update
|
||||
// totalCount. When matchesCurrentSearch is false, the user
|
||||
// is mid-search; leave the search's totalCount in place so
|
||||
// pagination math stays correct.
|
||||
mergeData(data);
|
||||
setTotalCount(totalCount);
|
||||
}
|
||||
} else if (page === 0) {
|
||||
// Replace cached options with server results; preserve
|
||||
// optimistic isNewOption entries inserted by handleOnSearch
|
||||
// so allowNewOptions users can still click the value they
|
||||
// typed when the server returns no match.
|
||||
setSelectOptions(prevOptions => {
|
||||
const dataValues = new Set(data.map(opt => opt.value));
|
||||
const preservedNew = prevOptions.filter(
|
||||
opt => opt.isNewOption && !dataValues.has(opt.value),
|
||||
);
|
||||
return preservedNew
|
||||
.concat(data)
|
||||
.sort(sortComparatorForNoSearch);
|
||||
});
|
||||
setTotalCount(totalCount);
|
||||
} else {
|
||||
// page > 0 during an active search — append normally.
|
||||
mergeData(data);
|
||||
setTotalCount(totalCount);
|
||||
}
|
||||
})
|
||||
.catch(internalOnError)
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
inFlightFetchesRef.current = Math.max(
|
||||
0,
|
||||
inFlightFetchesRef.current - 1,
|
||||
);
|
||||
if (inFlightFetchesRef.current === 0) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
@@ -358,6 +425,7 @@ const AsyncSelect = forwardRef(
|
||||
internalOnError,
|
||||
options,
|
||||
pageSize,
|
||||
sortComparatorForNoSearch,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -500,6 +568,7 @@ const AsyncSelect = forwardRef(
|
||||
fetchedQueries.current.clear();
|
||||
setAllValuesLoaded(false);
|
||||
setSelectOptions(EMPTY_OPTIONS);
|
||||
initialOptionsRef.current = EMPTY_OPTIONS;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -514,16 +583,36 @@ const AsyncSelect = forwardRef(
|
||||
[debouncedFetchPage],
|
||||
);
|
||||
|
||||
const previousInputValue = usePrevious(inputValue, '');
|
||||
useEffect(() => {
|
||||
if (loadingEnabled && allowFetch) {
|
||||
// trigger fetch every time inputValue changes
|
||||
if (inputValue) {
|
||||
debouncedFetchPage(inputValue, 0);
|
||||
} else {
|
||||
// Cancel any pending debounced search fetch so it can't fire after
|
||||
// we've already restored the base list.
|
||||
debouncedFetchPage.cancel();
|
||||
// On returning to empty input after a search, restore the cached
|
||||
// base options so the dropdown shows the original page-0 list
|
||||
// instead of the stale search results.
|
||||
if (previousInputValue && initialOptionsRef.current.length > 0) {
|
||||
setSelectOptions(
|
||||
[...initialOptionsRef.current].sort(sortComparatorForNoSearch),
|
||||
);
|
||||
}
|
||||
fetchPage('', 0);
|
||||
}
|
||||
}
|
||||
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
|
||||
}, [
|
||||
loadingEnabled,
|
||||
fetchPage,
|
||||
allowFetch,
|
||||
inputValue,
|
||||
previousInputValue,
|
||||
debouncedFetchPage,
|
||||
sortComparatorForNoSearch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading !== undefined && loading !== isLoading) {
|
||||
@@ -531,7 +620,11 @@ const AsyncSelect = forwardRef(
|
||||
}
|
||||
}, [isLoading, loading]);
|
||||
|
||||
const clearCache = () => fetchedQueries.current.clear();
|
||||
const clearCache = () => {
|
||||
fetchedQueries.current.clear();
|
||||
initialOptionsRef.current = EMPTY_OPTIONS;
|
||||
setAllValuesLoaded(false);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
||||
@@ -211,6 +211,10 @@ export const handleFilterOptionHelper = (
|
||||
return filterOption(search, option);
|
||||
}
|
||||
|
||||
if (filterOption === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterOption) {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
if (optionFilterProps?.length) {
|
||||
|
||||
@@ -371,3 +371,37 @@ test('should handle large datasets with pagination', () => {
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should reset to first page when data reduces below current page', async () => {
|
||||
// Start with 30 items, 10 per page = 3 pages
|
||||
const initialData = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: i,
|
||||
age: 20 + i,
|
||||
name: `Person ${i}`,
|
||||
}));
|
||||
|
||||
const props = {
|
||||
...mockedProps,
|
||||
data: initialData,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TableView {...props} />);
|
||||
|
||||
// Navigate to page 3 (last page)
|
||||
const page3 = screen.getByRole('listitem', { name: '3' });
|
||||
await userEvent.click(page3);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('21-30 of 30')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reduce data to only 5 items (fewer than current page would show)
|
||||
const reducedData = initialData.slice(0, 5);
|
||||
rerender(<TableView {...props} data={reducedData} />);
|
||||
|
||||
// Should reset to page 1 since page 3 no longer exists
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1-5 of 5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,6 +246,21 @@ const RawTableView = ({
|
||||
}
|
||||
}, [initialSortBy, onServerPagination, serverPagination, sortBy]);
|
||||
|
||||
// Reset to first page when current page exceeds available pages
|
||||
// (e.g., when filtering reduces the data below the current page)
|
||||
const pageCount = Math.ceil(data.length / effectivePageSize);
|
||||
useEffect(() => {
|
||||
if (
|
||||
withPagination &&
|
||||
!serverPagination &&
|
||||
!loading &&
|
||||
pageIndex > pageCount - 1 &&
|
||||
pageCount > 0
|
||||
) {
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [withPagination, serverPagination, loading, pageIndex, pageCount]);
|
||||
|
||||
return (
|
||||
<TableViewStyles {...props} ref={tableRef}>
|
||||
<TableCollection
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface ChartDataResponseResult {
|
||||
// TODO(hainenber): define proper type for below attributes
|
||||
rejected_filters?: any[];
|
||||
applied_filters?: any[];
|
||||
warning?: string | null;
|
||||
/**
|
||||
* Detected ISO 4217 currency code when AUTO mode is used.
|
||||
* Returns the currency code if all filtered data contains a single currency,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"acorn": "^8.16.0",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -27,7 +27,7 @@ jest.mock('../../DeckGLContainer', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../../factory', () => ({
|
||||
createDeckGLComponent: jest.fn(() => () => null),
|
||||
createCategoricalDeckGLComponent: jest.fn(() => () => null),
|
||||
GetLayerType: {},
|
||||
}));
|
||||
|
||||
@@ -53,6 +53,14 @@ const mockPayload = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockLayerParams = {
|
||||
onContextMenu: jest.fn(),
|
||||
filterState: undefined,
|
||||
setDataMask: jest.fn(),
|
||||
setTooltip: jest.fn(),
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('getLayer uses line_width_unit from formData', () => {
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
@@ -117,3 +125,518 @@ test('getPoints extracts points from path data', () => {
|
||||
expect(points[0]).toEqual([0, 0]);
|
||||
expect(points[2]).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
test('Fixed width mode returns constant width for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map(d => d.width);
|
||||
|
||||
widths.forEach(width => {
|
||||
expect(width).toBe(widths[0]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fixed width mode applies multiplier correctly', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width_multiplier: 3,
|
||||
min_width: 1,
|
||||
max_width: 100,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(15);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces minimum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 2,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces maximum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('Fixed width mode defaults width to 1 when no width is provided', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: undefined,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(1);
|
||||
});
|
||||
|
||||
test('Metric mode normalizes widths proportionally between min and max bounds', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map((d: any) => d.width);
|
||||
|
||||
expect(widths[0]).toBeCloseTo(1);
|
||||
expect(widths[1]).toBeCloseTo(10.5);
|
||||
expect(widths[2]).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Metric mode applies multiplier after normalization', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 2,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBeCloseTo(2);
|
||||
expect(data[1].width).toBe(20);
|
||||
});
|
||||
|
||||
test('Metric mode enforces bounds after multiplier', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 5,
|
||||
max_width: 15,
|
||||
line_width_multiplier: 10,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.width).toBeGreaterThanOrEqual(5);
|
||||
expect(d.width).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
test('Metric mode handles equal width values.', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBe(data[1].width);
|
||||
});
|
||||
|
||||
test('Metric mode handles null width values', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: null,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[1].width).toBe(1);
|
||||
expect(data[0].width).toBeCloseTo(1);
|
||||
expect(data[2].width).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Fixed color mode returns same color for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
color_picker: { r: 255, g: 100, b: 50, a: 1 },
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const expectedColor = [255, 100, 50, 255];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.color).toEqual(expectedColor);
|
||||
});
|
||||
});
|
||||
|
||||
test('Categorical mode preserves distinct colors for selected categories', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
cat_color: 'B',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
test('Breakpoint mode preserves colors assigned by addColor based on metric ranges', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 50,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
metric: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 75,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
@@ -21,13 +21,14 @@ import { PathLayer } from '@deck.gl/layers';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
import { commonLayerProps } from '../common';
|
||||
import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
||||
import { Point } from '../../types';
|
||||
import {
|
||||
createTooltipContent,
|
||||
CommonTooltipRows,
|
||||
} from '../../utilities/tooltipUtils';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
function setTooltipContent(formData: QueryFormData) {
|
||||
const defaultTooltipGenerator = (o: JsonObject) => (
|
||||
@@ -50,14 +51,69 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
emitCrossFilters,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const c = fd.color_picker;
|
||||
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
if (feature.color) {
|
||||
return { ...feature };
|
||||
}
|
||||
|
||||
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const color = [c.r, c.g, c.b, 255 * c.a];
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
// Variables for width scaling and normalization
|
||||
const minWidth = Number(fd.min_width) || 1; // defaulted to 1
|
||||
const maxWidth = Number(fd.max_width) || 20; // defaulted to 20
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1; // defaulted to 1
|
||||
|
||||
const widths = data.map((d: JsonObject) => d.width).filter(Number.isFinite);
|
||||
|
||||
// Metric or fixed value
|
||||
const isMetricWidth = isMetricValue(fd.line_width);
|
||||
|
||||
if (isMetricWidth) {
|
||||
// Get minimum and maximum widths in data set
|
||||
const minVal = widths.length > 0 ? Math.min(...widths) : minWidth;
|
||||
const maxVal = widths.length > 0 ? Math.max(...widths) : maxWidth;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
if (d.width == null) return { ...d, width: minWidth };
|
||||
|
||||
const normalized =
|
||||
maxVal === minVal ? 0.5 : (d.width - minVal) / (maxVal - minVal);
|
||||
|
||||
// Map within range of min + max
|
||||
let width = minWidth + normalized * (maxWidth - minWidth);
|
||||
|
||||
// Apply scaling multiplier
|
||||
width *= multiplier;
|
||||
|
||||
// Enforce minimum and maximum width bounds
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return { ...d, width };
|
||||
});
|
||||
} else {
|
||||
// Fixed width mode
|
||||
// Allows for use with legacy charts
|
||||
const fixedWidth =
|
||||
typeof fd.line_width === 'number'
|
||||
? fd.line_width
|
||||
: typeof fd.line_width === 'object' && fd.line_width?.type === 'fix'
|
||||
? Number(fd.line_width.value)
|
||||
: undefined;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
let width = (d.width ?? fixedWidth ?? 1) * multiplier;
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
return { ...d, width };
|
||||
});
|
||||
}
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -66,13 +122,15 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
|
||||
return new PathLayer({
|
||||
id: `path-layer-${fd.slice_id}` as const,
|
||||
getColor: (d: any) => d.color,
|
||||
getColor: (d: any) => d.color || [0, 0, 0, 255],
|
||||
getPath: (d: any) => d.path,
|
||||
getWidth: (d: any) => d.width,
|
||||
data,
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
...commonLayerProps({
|
||||
formData: fd,
|
||||
setTooltip,
|
||||
@@ -101,13 +159,23 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const minWidth = Number(fd.min_width) || 1;
|
||||
const maxWidth = Number(fd.max_width) || 20;
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1;
|
||||
const fixedColor = HIGHLIGHT_COLOR_ARRAY;
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
const baseWidth = Number.isFinite(feature.width) ? feature.width : 1;
|
||||
let width = baseWidth * multiplier;
|
||||
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width,
|
||||
color: fixedColor,
|
||||
};
|
||||
});
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -128,7 +196,13 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
export default createCategoricalDeckGLComponent(
|
||||
getLayer,
|
||||
getPoints,
|
||||
getHighlightLayer,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import buildQuery, { DeckPathFormData } from './buildQuery';
|
||||
|
||||
const baseFormData: DeckPathFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'deck_path',
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
row_limit: 100,
|
||||
};
|
||||
|
||||
test('Path buildQuery should not include metric when line_width is fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle numeric line_width value with fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing line_width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should include metric when line_width is metric type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'COUNT(*)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SQL metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'custom_width',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'SUM(weight) / COUNT(*)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SIMPLE metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'AVG(traffic)',
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'traffic' },
|
||||
aggregate: 'AVG' as const,
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle metric type with undefined value', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate width metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(weight)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Path buildQuery should preserve existing metrics when adding width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
expect(query.metrics).toContain('AVG(weight)');
|
||||
expect(query.metrics).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not modify existing metrics for fixed width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)', 'SUM(value)'],
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual(['COUNT(*)', 'SUM(value)']);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle undefined value in metric type gracefully', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
// Should not add anything when value is undefined
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width with undefined type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: undefined,
|
||||
value: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Dimension (categorical color) ───
|
||||
|
||||
test('Path buildQuery should include dimension column when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
dimension: 'route_type',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.columns).toContain('route_type');
|
||||
});
|
||||
|
||||
test('Path buildQuery should include breakpoint_metric when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using breakpoint metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate breakpoint metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(speed)'],
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle breakpoint_metric and line_width metric together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc breakpoint metric', () => {
|
||||
const adhocMetric = {
|
||||
label: 'avg_speed',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'AVG(speed_mph)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: adhocMetric,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing breakpoint_metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width and breakpoint_metrics together together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
js_columns: ['color'],
|
||||
tooltip_contents: ['name'],
|
||||
row_limit: 500,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
expect(query.columns).toContain('color');
|
||||
expect(query.columns).toContain('name');
|
||||
expect(query.row_limit).toBe(500);
|
||||
});
|
||||
@@ -19,10 +19,13 @@
|
||||
import {
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
getMetricLabel,
|
||||
SqlaFormData,
|
||||
QueryFormColumn,
|
||||
QueryFormMetric,
|
||||
} from '@superset-ui/core';
|
||||
import { addNullFilters, addTooltipColumnsToQuery } from '../buildQueryUtils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
export interface DeckPathFormData extends SqlaFormData {
|
||||
line_column?: string;
|
||||
@@ -32,10 +35,26 @@ export interface DeckPathFormData extends SqlaFormData {
|
||||
js_columns?: string[];
|
||||
tooltip_contents?: unknown[];
|
||||
tooltip_template?: string;
|
||||
line_width?:
|
||||
| string
|
||||
| { type?: 'fix' | 'metric'; value?: QueryFormMetric | number };
|
||||
line_width_multiplier?: number;
|
||||
min_width?: number;
|
||||
max_width?: number;
|
||||
dimension?: string;
|
||||
breakpoint_metric?: QueryFormMetric;
|
||||
}
|
||||
|
||||
export default function buildQuery(formData: DeckPathFormData) {
|
||||
const { line_column, metric, js_columns, tooltip_contents } = formData;
|
||||
const {
|
||||
line_column,
|
||||
metric,
|
||||
js_columns,
|
||||
tooltip_contents,
|
||||
line_width,
|
||||
dimension,
|
||||
breakpoint_metric,
|
||||
} = formData;
|
||||
|
||||
if (!line_column) {
|
||||
throw new Error('Line column is required for Path charts');
|
||||
@@ -46,7 +65,7 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
const columns = ensureIsArray(
|
||||
baseQueryObject.columns || [],
|
||||
) as QueryFormColumn[];
|
||||
const metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
let metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
const groupby = ensureIsArray(
|
||||
baseQueryObject.groupby || [],
|
||||
) as QueryFormColumn[];
|
||||
@@ -63,6 +82,49 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
columns.push(line_column);
|
||||
}
|
||||
|
||||
// Include dimension column for categorical color mode
|
||||
if (dimension && !columns.includes(dimension)) {
|
||||
columns.push(dimension);
|
||||
}
|
||||
|
||||
// Add metric if line_width is a metric type
|
||||
const isMetric = isMetricValue(line_width);
|
||||
const rawWidthValue =
|
||||
typeof line_width === 'string'
|
||||
? line_width
|
||||
: typeof line_width === 'number'
|
||||
? undefined
|
||||
: line_width?.value;
|
||||
const widthMetric: QueryFormMetric | null =
|
||||
isMetric &&
|
||||
rawWidthValue !== undefined &&
|
||||
typeof rawWidthValue !== 'number'
|
||||
? (rawWidthValue as QueryFormMetric)
|
||||
: null;
|
||||
|
||||
// ensure metric is not added to metric array twice
|
||||
const existingLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (widthMetric && !existingLabels.has(getMetricLabel(widthMetric))) {
|
||||
metrics = [...metrics, widthMetric];
|
||||
}
|
||||
|
||||
// ensure line_column is in groupby when aggregating by width metric
|
||||
if (widthMetric && !groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
|
||||
if (breakpoint_metric) {
|
||||
const breakpointLabel = getMetricLabel(breakpoint_metric);
|
||||
const currentLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (!currentLabels.has(breakpointLabel)) {
|
||||
metrics = [...metrics, breakpoint_metric];
|
||||
}
|
||||
// ensure line_column is in groupby when aggregating
|
||||
if (!groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
}
|
||||
|
||||
jsColumns.forEach(col => {
|
||||
if (!columns.includes(col) && !groupby.includes(col)) {
|
||||
columns.push(col);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type {
|
||||
ControlPanelSectionConfig,
|
||||
ControlSetRow,
|
||||
ControlSetItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
test('controlPanel should have Path Size section', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
expect(pathSizeSection).toBeDefined();
|
||||
expect(pathSizeSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should include pathLineWidthFixedOrMetric control', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const control = pathSizeSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width',
|
||||
) as any;
|
||||
|
||||
expect(control).toBeDefined();
|
||||
expect(control.config.type).toBe('FixedOrMetricControl');
|
||||
expect(control.config.default).toEqual({ type: 'fix', value: 1 });
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_unit control with pixels as default', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthRow = pathSizeSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthControl = lineWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthControl).toBeDefined();
|
||||
expect(lineWidthControl?.config?.default).toBe('pixels');
|
||||
});
|
||||
|
||||
test('controlPanel should include min_width control with default of 1', () => {
|
||||
const minWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const minWidthRow = minWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
),
|
||||
);
|
||||
|
||||
const minWidthControl = minWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
) as any;
|
||||
|
||||
expect(minWidthControl).toBeDefined();
|
||||
expect(minWidthControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should include max_width control with default of 20', () => {
|
||||
const maxWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const maxWidthRow = maxWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
),
|
||||
);
|
||||
|
||||
const maxWidthControl = maxWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
) as any;
|
||||
|
||||
expect(maxWidthControl).toBeDefined();
|
||||
expect(maxWidthControl?.config?.default).toBe(20);
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_multiplier control with default of 1', () => {
|
||||
const lineWidthMultiplierSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthMultiplierRow =
|
||||
lineWidthMultiplierSection?.controlSetRows.find((row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthMultiplierControl = lineWidthMultiplierRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthMultiplierControl).toBeDefined();
|
||||
expect(lineWidthMultiplierControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
expect(pathColorSection).toBeDefined();
|
||||
expect(pathColorSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section with color scheme controls', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const controlNames = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.filter(
|
||||
(control: ControlSetItem) =>
|
||||
control && typeof control === 'object' && 'name' in control,
|
||||
)
|
||||
.map((control: any) => control.name);
|
||||
|
||||
expect(controlNames).toContain('color_scheme_type');
|
||||
expect(controlNames).toContain('color_picker');
|
||||
expect(controlNames).toContain('dimension');
|
||||
expect(controlNames).toContain('color_scheme');
|
||||
expect(controlNames).toContain('breakpoint_metric');
|
||||
expect(controlNames).toContain('default_breakpoint_color');
|
||||
expect(controlNames).toContain('color_breakpoints');
|
||||
});
|
||||
|
||||
test('color_scheme_type should default to fixed_color', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const schemeTypeControl = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'color_scheme_type',
|
||||
) as any;
|
||||
|
||||
expect(schemeTypeControl).toBeDefined();
|
||||
expect(schemeTypeControl?.config?.default).toBe('fixed_color');
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
jsTooltip,
|
||||
jsOnclickHref,
|
||||
viewport,
|
||||
lineWidth,
|
||||
lineType,
|
||||
reverseLongLat,
|
||||
mapboxStyle,
|
||||
@@ -34,8 +33,12 @@ import {
|
||||
mapProvider,
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
pathLineWidthFixedOrMetric,
|
||||
generateDeckGLColorSchemeControls,
|
||||
} from '../../utilities/Shared_DeckGL';
|
||||
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
@@ -71,25 +74,83 @@ const config: ControlPanelConfig = {
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[viewport],
|
||||
['color_picker'],
|
||||
[lineWidth],
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Size'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[pathLineWidthFixedOrMetric],
|
||||
[
|
||||
{
|
||||
name: 'line_width_unit',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Line width unit'),
|
||||
default: 'meters',
|
||||
default: 'pixels',
|
||||
choices: [
|
||||
['meters', t('meters')],
|
||||
['pixels', t('pixels')],
|
||||
],
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
[
|
||||
{
|
||||
name: 'min_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Minimum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Minimum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'max_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Maximum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 20,
|
||||
description: t(
|
||||
'Maximum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'line_width_multiplier',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Width scale multiplier'),
|
||||
renderTrigger: true,
|
||||
isFloat: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Scale factor applied to metric-driven line widths',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Color'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
...generateDeckGLColorSchemeControls({
|
||||
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, DatasourceType } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
interface PathFeature {
|
||||
path: [number, number][];
|
||||
width?: number;
|
||||
metric?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const samplePath1 = JSON.stringify([
|
||||
[-122.4, 37.8],
|
||||
[-122.3, 37.9],
|
||||
]);
|
||||
const samplePath2 = JSON.stringify([
|
||||
[-122.5, 37.7],
|
||||
[-122.4, 37.8],
|
||||
]);
|
||||
const samplePath3 = JSON.stringify([
|
||||
[-122.6, 37.6],
|
||||
[-122.5, 37.7],
|
||||
]);
|
||||
|
||||
const mockChartProps: Partial<ChartProps> = {
|
||||
rawFormData: {
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
viewport: {},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
path_json: samplePath1,
|
||||
'AVG(weight)': 100,
|
||||
'SUM(distance)': 500,
|
||||
route_type: 'express',
|
||||
},
|
||||
{
|
||||
path_json: samplePath2,
|
||||
'AVG(weight)': 200,
|
||||
'SUM(distance)': 1000,
|
||||
route_type: 'local',
|
||||
},
|
||||
{
|
||||
path_json: samplePath3,
|
||||
'AVG(weight)': 50,
|
||||
'SUM(distance)': 250,
|
||||
route_type: 'express',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
datasource: {
|
||||
type: DatasourceType.Table,
|
||||
id: 1,
|
||||
name: 'test_datasource',
|
||||
columns: [],
|
||||
metrics: [],
|
||||
},
|
||||
height: 400,
|
||||
width: 600,
|
||||
hooks: {},
|
||||
filterState: {},
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('Path transformProps should parse JSON paths correctly', () => {
|
||||
const result = transformProps(mockChartProps as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.path).toBeDefined();
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
expect(f.path.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should handle empty records', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [{ data: [] }],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle missing line_column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_column: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle invalid JSON path data', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ path_json: 'not valid json' }, { path_json: '12345' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(2);
|
||||
// Should not throw, paths should be empty arrays
|
||||
features.forEach(f => {
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width value when line_width type is "fix"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width with string value', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should not set width when line_width is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use metric value for width when line_width type is "metric"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.width).toBe(50);
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric from breakpoint_metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should fall back to base metric when breakpoint_metric is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'AVG(weight)',
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should include both breakpoint_metric and width metrics if they are different', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(result.payload.data.metricLabels).toEqual([
|
||||
'AVG(weight)',
|
||||
'SUM(distance)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Path transformProps should not include both breakpoint_metric and width metrics if they are the same', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'SUM(distance)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual(['SUM(distance)']);
|
||||
});
|
||||
|
||||
test('Path transformProps should set cat_color from dimension column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
dimension: 'route_type',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.cat_color).toBe('express');
|
||||
expect(features[1]?.cat_color).toBe('local');
|
||||
expect(features[2]?.cat_color).toBe('express');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels when breakpoint_metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('AVG(weight)');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels from base metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('SUM(distance)');
|
||||
});
|
||||
|
||||
test('Path transformProps should have empty metric labels when no metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: undefined,
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual([]);
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, DTTM_ALIAS } from '@superset-ui/core';
|
||||
import { ChartProps, DTTM_ALIAS, getMetricLabel } from '@superset-ui/core';
|
||||
import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils';
|
||||
import {
|
||||
createBaseTransformResult,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
addPropertiesToFeature,
|
||||
} from '../transformUtils';
|
||||
import { DeckPathFormData } from './buildQuery';
|
||||
import { isFixedValue, getFixedValue } from '../utils/metricUtils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -48,6 +49,8 @@ interface PathFeature {
|
||||
path: [number, number][];
|
||||
metric?: number;
|
||||
timestamp?: unknown;
|
||||
width?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -91,6 +94,9 @@ function processPathData(
|
||||
reverseLongLat: boolean = false,
|
||||
metricLabel?: string,
|
||||
jsColumns?: string[],
|
||||
widthMetricLabel?: string,
|
||||
fixedWidthValue?: number | string | null,
|
||||
categoryColumn?: string,
|
||||
): PathFeature[] {
|
||||
if (!records.length || !lineColumn) {
|
||||
return [];
|
||||
@@ -103,6 +109,8 @@ function processPathData(
|
||||
'timestamp',
|
||||
DTTM_ALIAS,
|
||||
metricLabel,
|
||||
widthMetricLabel,
|
||||
categoryColumn,
|
||||
...(jsColumns || []),
|
||||
].filter(Boolean) as string[],
|
||||
);
|
||||
@@ -130,6 +138,24 @@ function processPathData(
|
||||
feature.metric = metricValue;
|
||||
}
|
||||
}
|
||||
// Set width from metric or fixed value
|
||||
if (fixedWidthValue != null) {
|
||||
// Use fixed width
|
||||
const parsedFixedWidth = parseMetricValue(fixedWidthValue);
|
||||
if (parsedFixedWidth !== undefined) {
|
||||
feature.width = parsedFixedWidth;
|
||||
}
|
||||
} else if (widthMetricLabel && record[widthMetricLabel] != null) {
|
||||
// Use metric value for width
|
||||
const widthValue = parseMetricValue(record[widthMetricLabel]);
|
||||
if (widthValue !== undefined) {
|
||||
feature.width = widthValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryColumn && record[categoryColumn] != null) {
|
||||
feature.cat_color = String(record[categoryColumn]);
|
||||
}
|
||||
|
||||
feature = addJsColumnsToExtraProps(feature, record, jsColumns);
|
||||
feature = addPropertiesToFeature(feature, record, excludeKeys);
|
||||
@@ -143,11 +169,37 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
line_column,
|
||||
line_type = 'json',
|
||||
metric,
|
||||
line_width,
|
||||
dimension,
|
||||
reverse_long_lat = false,
|
||||
js_columns,
|
||||
breakpoint_metric,
|
||||
} = formData as DeckPathTransformPropsFormData;
|
||||
|
||||
const metricLabel = getMetricLabelFromFormData(metric);
|
||||
// Check so legacy values still work
|
||||
const fixedWidthValue =
|
||||
typeof line_width === 'number'
|
||||
? line_width
|
||||
: isFixedValue(line_width)
|
||||
? getFixedValue(line_width)
|
||||
: undefined;
|
||||
|
||||
const widthMetricLabel = getMetricLabelFromFormData(line_width);
|
||||
|
||||
const breakpointMetricLabel = breakpoint_metric
|
||||
? getMetricLabel(breakpoint_metric)
|
||||
: undefined;
|
||||
const baseMetricLabel = getMetricLabelFromFormData(metric);
|
||||
const metricLabel = breakpointMetricLabel || baseMetricLabel;
|
||||
|
||||
// ensure all metric labels are included
|
||||
const metricLabels = [
|
||||
...(metricLabel ? [metricLabel] : []),
|
||||
...(widthMetricLabel && widthMetricLabel !== metricLabel
|
||||
? [widthMetricLabel]
|
||||
: []),
|
||||
];
|
||||
|
||||
const records = getRecordsFromQuery(chartProps.queriesData);
|
||||
const features = processPathData(
|
||||
records,
|
||||
@@ -156,11 +208,10 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
reverse_long_lat,
|
||||
metricLabel,
|
||||
js_columns,
|
||||
widthMetricLabel,
|
||||
fixedWidthValue,
|
||||
dimension,
|
||||
).reverse();
|
||||
|
||||
return createBaseTransformResult(
|
||||
chartProps,
|
||||
features,
|
||||
metricLabel ? [metricLabel] : [],
|
||||
);
|
||||
return createBaseTransformResult(chartProps, features, metricLabels);
|
||||
}
|
||||
|
||||
@@ -285,6 +285,22 @@ export const lineWidth = {
|
||||
},
|
||||
};
|
||||
|
||||
// created new const so as not to break lineWidth usages in other charts
|
||||
export const pathLineWidthFixedOrMetric = {
|
||||
name: 'line_width',
|
||||
config: {
|
||||
type: 'FixedOrMetricControl', // using existing type
|
||||
label: t('Line width'),
|
||||
default: { type: 'fix', value: 1 }, // kept same default as before
|
||||
description: t(
|
||||
'The width of the lines as either a fixed value or variable width based on a metric.',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => ({
|
||||
datasource: state.datasource,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const fillColorPicker: CustomControlItem = {
|
||||
name: 'fill_color_picker',
|
||||
config: {
|
||||
@@ -673,6 +689,24 @@ export const deckGLColorBreakpointsSelect: CustomControlItem = {
|
||||
},
|
||||
};
|
||||
|
||||
export const deckGLBreakpointMetric: CustomControlItem = {
|
||||
name: 'breakpoint_metric',
|
||||
config: {
|
||||
...sharedControls.metric,
|
||||
label: t('Breakpoint Metric'),
|
||||
default: null,
|
||||
validators: [],
|
||||
description: t(
|
||||
'Select the metric used to determine which color breakpoint range each path falls into.',
|
||||
),
|
||||
// mapStateToProps: (state: ControlPanelState) => ({
|
||||
// datasource: state.datasource,
|
||||
// }),
|
||||
visibility: ({ controls }: { controls: any }) =>
|
||||
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
|
||||
},
|
||||
};
|
||||
|
||||
export const breakpointsDefaultColor: CustomControlItem = {
|
||||
name: 'default_breakpoint_color',
|
||||
config: {
|
||||
@@ -725,6 +759,7 @@ export const generateDeckGLColorSchemeControls = ({
|
||||
[deckGLFixedColor],
|
||||
disableCategoricalColumn ? [] : [deckGLCategoricalColor],
|
||||
[deckGLCategoricalColorSchemeSelect],
|
||||
[deckGLBreakpointMetric],
|
||||
[breakpointsDefaultColor],
|
||||
[deckGLColorBreakpointsSelect],
|
||||
];
|
||||
|
||||
@@ -47,6 +47,13 @@ const Title = styled.h4`
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
const StyledTabs = styled(Tabs)`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * -8}px;
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
|
||||
const ssql = sql || '';
|
||||
let lines = ssql.split('\n');
|
||||
@@ -94,7 +101,7 @@ function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
<StyledTabs
|
||||
defaultActiveKey="executed"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import config from './controlPanel';
|
||||
|
||||
type ControlConfig = {
|
||||
label?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
type ControlItem = {
|
||||
config: ControlConfig;
|
||||
} | null;
|
||||
|
||||
function collectFunctionProps(cfg: typeof config) {
|
||||
const fns: Array<() => unknown> = [];
|
||||
cfg.controlPanelSections.forEach(section => {
|
||||
section?.controlSetRows.forEach(row => {
|
||||
(row as ControlItem[]).forEach(item => {
|
||||
if (item && typeof item === 'object' && 'config' in item) {
|
||||
const { label, description } = item.config;
|
||||
if (typeof label === 'function') fns.push(label as () => unknown);
|
||||
if (typeof description === 'function')
|
||||
fns.push(description as () => unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return fns;
|
||||
}
|
||||
|
||||
test('DynamicGroupBy controlPanel label and description functions return strings', () => {
|
||||
const fns = collectFunctionProps(config);
|
||||
expect(fns.length).toBeGreaterThan(0);
|
||||
fns.forEach(fn => {
|
||||
expect(typeof fn()).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -49,12 +49,12 @@ const config: ControlPanelConfig = {
|
||||
name: 'canSelectMultiple',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Can select multiple values'),
|
||||
label: () => t('Can select multiple values'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
resetConfig: true,
|
||||
affectsDataMask: true,
|
||||
description: t('Allow users to select multiple values'),
|
||||
description: () => t('Allow users to select multiple values'),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -63,12 +63,13 @@ const config: ControlPanelConfig = {
|
||||
name: 'enableEmptyFilter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Chart customization value is required'),
|
||||
label: () => t('Chart customization value is required'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'User must select a value before applying the chart customization',
|
||||
),
|
||||
description: () =>
|
||||
t(
|
||||
'User must select a value before applying the chart customization',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import config from './controlPanel';
|
||||
|
||||
type ControlConfig = {
|
||||
label?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
type ControlItem = {
|
||||
config: ControlConfig;
|
||||
} | null;
|
||||
|
||||
function collectFunctionProps(cfg: typeof config) {
|
||||
const fns: Array<() => unknown> = [];
|
||||
cfg.controlPanelSections.forEach(section => {
|
||||
section?.controlSetRows.forEach(row => {
|
||||
(row as ControlItem[]).forEach(item => {
|
||||
if (item && typeof item === 'object' && 'config' in item) {
|
||||
const { label, description } = item.config;
|
||||
if (typeof label === 'function') fns.push(label as () => unknown);
|
||||
if (typeof description === 'function')
|
||||
fns.push(description as () => unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return fns;
|
||||
}
|
||||
|
||||
test('TimeColumn controlPanel label and description functions return strings', () => {
|
||||
const fns = collectFunctionProps(config);
|
||||
expect(fns.length).toBeGreaterThan(0);
|
||||
fns.forEach(fn => {
|
||||
expect(typeof fn()).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -30,12 +30,11 @@ const config: ControlPanelConfig = {
|
||||
name: 'enableEmptyFilter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Filter value is required'),
|
||||
label: () => t('Filter value is required'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'User must select a value before applying the filter',
|
||||
),
|
||||
description: () =>
|
||||
t('User must select a value before applying the filter'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import config from './controlPanel';
|
||||
|
||||
type ControlConfig = {
|
||||
label?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
type ControlItem = {
|
||||
config: ControlConfig;
|
||||
} | null;
|
||||
|
||||
function collectFunctionProps(cfg: typeof config) {
|
||||
const fns: Array<() => unknown> = [];
|
||||
cfg.controlPanelSections.forEach(section => {
|
||||
section?.controlSetRows.forEach(row => {
|
||||
(row as ControlItem[]).forEach(item => {
|
||||
if (item && typeof item === 'object' && 'config' in item) {
|
||||
const { label, description } = item.config;
|
||||
if (typeof label === 'function') fns.push(label as () => unknown);
|
||||
if (typeof description === 'function')
|
||||
fns.push(description as () => unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return fns;
|
||||
}
|
||||
|
||||
test('TimeGrain controlPanel label and description functions return strings', () => {
|
||||
const fns = collectFunctionProps(config);
|
||||
expect(fns.length).toBeGreaterThan(0);
|
||||
fns.forEach(fn => {
|
||||
expect(typeof fn()).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -30,12 +30,11 @@ const config: ControlPanelConfig = {
|
||||
name: 'enableEmptyFilter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Customization value is required'),
|
||||
label: () => t('Customization value is required'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'User must select a value before applying the customization',
|
||||
),
|
||||
description: () =>
|
||||
t('User must select a value before applying the customization'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
getQuerySettings,
|
||||
getChartDataUri,
|
||||
} from 'src/explore/exploreUtils';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
addDangerToast,
|
||||
addWarningToast,
|
||||
} from 'src/components/MessageToasts/actions';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
|
||||
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
|
||||
@@ -813,6 +816,11 @@ export function exploreJSON(
|
||||
}),
|
||||
),
|
||||
);
|
||||
(queriesResponse as QueryData[]).forEach(response => {
|
||||
if (response.warning) {
|
||||
dispatch(addWarningToast(response.warning, { noDuplicate: true }));
|
||||
}
|
||||
});
|
||||
return dispatch(
|
||||
chartUpdateSucceeded(queriesResponse as QueryData[], key as number),
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
AnnotationSourceType,
|
||||
AnnotationStyle,
|
||||
} from '@superset-ui/core';
|
||||
import * as toastActions from 'src/components/MessageToasts/actions';
|
||||
import { LOG_EVENT } from 'src/logger/actions';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import * as actions from 'src/components/Chart/chartAction';
|
||||
@@ -412,6 +413,56 @@ describe('chart actions', () => {
|
||||
);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('dispatches addWarningToast when a query response includes a warning', async () => {
|
||||
const warningMessage =
|
||||
'Results truncated to 1,000 rows due to memory constraints.';
|
||||
fetchMock.removeRoute(MOCK_URL);
|
||||
fetchMock.post(
|
||||
`glob:*${MOCK_URL}*`,
|
||||
{ result: [{ warning: warningMessage }] },
|
||||
{ name: MOCK_URL },
|
||||
);
|
||||
const addWarningToastSpy = jest.spyOn(toastActions, 'addWarningToast');
|
||||
|
||||
const actionThunk = actions.postChartFormData(
|
||||
{ viz_type: 'my_viz' } as QueryFormData,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
await actionThunk(
|
||||
dispatch as unknown as actions.ChartThunkDispatch,
|
||||
mockGetState as unknown as () => actions.RootState,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(addWarningToastSpy).toHaveBeenCalledWith(warningMessage, {
|
||||
noDuplicate: true,
|
||||
});
|
||||
addWarningToastSpy.mockRestore();
|
||||
fetchMock.removeRoute(MOCK_URL);
|
||||
setupDefaultFetchMock();
|
||||
});
|
||||
|
||||
test('does not dispatch addWarningToast when no query response has a warning', async () => {
|
||||
const addWarningToastSpy = jest.spyOn(toastActions, 'addWarningToast');
|
||||
|
||||
const actionThunk = actions.postChartFormData(
|
||||
{ viz_type: 'my_viz' } as QueryFormData,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
await actionThunk(
|
||||
dispatch as unknown as actions.ChartThunkDispatch,
|
||||
mockGetState as unknown as () => actions.RootState,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(addWarningToastSpy).not.toHaveBeenCalled();
|
||||
addWarningToastSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -34,6 +34,7 @@ import { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
|
||||
import { GetState, LayoutItem, RootState } from '../types';
|
||||
import { updateLayoutComponents } from './dashboardFilters';
|
||||
import { setUnsavedChanges } from './dashboardState';
|
||||
|
||||
type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
|
||||
|
||||
// Component CRUD -------------------------------------------------------------
|
||||
|
||||
@@ -118,7 +118,7 @@ const NewChartButtonContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: ${theme.sizeUnit * 2}px;
|
||||
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -234,6 +234,26 @@ describe('ControlPanelsContainer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders section with function label and description', async () => {
|
||||
getChartControlPanelRegistry().remove('table');
|
||||
getChartControlPanelRegistry().registerValue('table', {
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: () => t('Dynamic Section Label'),
|
||||
description: () => t('Dynamic section description'),
|
||||
expanded: true,
|
||||
controlSetRows: [['groupby']],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ControlPanelsContainer {...getDefaultProps()} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dynamic Section Label')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden state of controls is correctly applied', async () => {
|
||||
getChartControlPanelRegistry().remove('table');
|
||||
getChartControlPanelRegistry().registerValue('table', {
|
||||
|
||||
@@ -630,10 +630,17 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
line-height: 1.3;
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
{typeof label === 'function' ? (label as () => ReactNode)() : label}
|
||||
</span>{' '}
|
||||
{description && (
|
||||
<Tooltip id={sectionId} title={description}>
|
||||
<Tooltip
|
||||
id={sectionId}
|
||||
title={
|
||||
typeof description === 'function'
|
||||
? (description as () => ReactNode)()
|
||||
: description
|
||||
}
|
||||
>
|
||||
<Icons.InfoCircleOutlined css={iconStyles} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -125,7 +125,7 @@ const getTooltipTitle = (
|
||||
) =>
|
||||
isLabelTruncated ? (
|
||||
<div>
|
||||
{label && <strong>{label}</strong>}
|
||||
{label && <strong>{t(label)}</strong>}
|
||||
{range && (
|
||||
<div
|
||||
css={(theme: SupersetTheme) => css`
|
||||
@@ -160,7 +160,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
const [timeRangeValue, setTimeRangeValue] = useState(value);
|
||||
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
|
||||
const [evalResponse, setEvalResponse] = useState<string>(value);
|
||||
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(value);
|
||||
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(t(value));
|
||||
const theme = useTheme();
|
||||
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
if (error) {
|
||||
setEvalResponse(error || '');
|
||||
setValidTimeRange(false);
|
||||
setTooltipTitle(value || null);
|
||||
setTooltipTitle(t(value) || null);
|
||||
} else {
|
||||
/*
|
||||
HRT == human readable text
|
||||
|
||||
@@ -486,6 +486,70 @@ test('sets comparator to undefined when operator is IS_NULL or IS_NOT_NULL', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('hides the value input when operator is IS_NULL', () => {
|
||||
setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.IsNull,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsNull].operation,
|
||||
comparator: undefined,
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter value (case sensitive)'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides the value input when operator is IS_NOT_NULL', () => {
|
||||
setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.IsNotNull,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsNotNull].operation,
|
||||
comparator: undefined,
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter value (case sensitive)'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides the value input when operator is IS_TRUE', () => {
|
||||
setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.IsTrue,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsTrue].operation,
|
||||
comparator: undefined,
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter value (case sensitive)'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides the value input when operator is IS_FALSE', () => {
|
||||
setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.IsFalse,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IsFalse].operation,
|
||||
comparator: undefined,
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter value (case sensitive)'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not call API when column has no advanced data type', async () => {
|
||||
const props = getAdvancedDataTypeTestProps();
|
||||
|
||||
|
||||
@@ -377,6 +377,14 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
const shouldFocusComparator =
|
||||
!!subjectSelectProps.value && !!operatorSelectProps.value;
|
||||
|
||||
const isUnaryOperator =
|
||||
operatorId !== undefined &&
|
||||
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators);
|
||||
|
||||
const hasComparatorOptions =
|
||||
(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
|
||||
suggestions.length > 0;
|
||||
|
||||
const comparatorSelectProps = {
|
||||
allowClear: true,
|
||||
allowNewOptions: true,
|
||||
@@ -389,9 +397,6 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
value: comparator as SelectValue,
|
||||
onChange: onComparatorChange,
|
||||
notFoundContent: t('Type a value here'),
|
||||
disabled:
|
||||
operatorId !== undefined &&
|
||||
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators),
|
||||
placeholder: createSuggestionsPlaceholder(),
|
||||
};
|
||||
|
||||
@@ -554,49 +559,45 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
}))}
|
||||
{...operatorSelectProps}
|
||||
/>
|
||||
{(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
|
||||
suggestions.length > 0 ? (
|
||||
<Tooltip
|
||||
title={
|
||||
advancedDataTypesState.errorMessage ||
|
||||
advancedDataTypesState.parsedAdvancedDataType
|
||||
}
|
||||
>
|
||||
<SelectWithLabel
|
||||
css={css`
|
||||
margin-top: ${theme.marginXS}px;
|
||||
`}
|
||||
labelText={labelText}
|
||||
options={suggestions}
|
||||
{...comparatorSelectProps}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
advancedDataTypesState.errorMessage ||
|
||||
advancedDataTypesState.parsedAdvancedDataType
|
||||
}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
margin-top: ${theme.marginXS}px;
|
||||
`}
|
||||
/>
|
||||
<Input
|
||||
data-test="adhoc-filter-simple-value"
|
||||
name="filter-value"
|
||||
ref={comparatorInputRef}
|
||||
onChange={onInputComparatorChange}
|
||||
value={typeof comparator === 'string' ? comparator : undefined}
|
||||
placeholder={t('Filter value (case sensitive)')}
|
||||
disabled={
|
||||
operatorId !== undefined &&
|
||||
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators)
|
||||
{!isUnaryOperator &&
|
||||
(hasComparatorOptions ? (
|
||||
<Tooltip
|
||||
title={
|
||||
advancedDataTypesState.errorMessage ||
|
||||
advancedDataTypesState.parsedAdvancedDataType
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<SelectWithLabel
|
||||
css={css`
|
||||
margin-top: ${theme.marginXS}px;
|
||||
`}
|
||||
labelText={labelText}
|
||||
options={suggestions}
|
||||
{...comparatorSelectProps}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
advancedDataTypesState.errorMessage ||
|
||||
advancedDataTypesState.parsedAdvancedDataType
|
||||
}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
margin-top: ${theme.marginXS}px;
|
||||
`}
|
||||
/>
|
||||
<Input
|
||||
data-test="adhoc-filter-simple-value"
|
||||
name="filter-value"
|
||||
ref={comparatorInputRef}
|
||||
onChange={onInputComparatorChange}
|
||||
value={typeof comparator === 'string' ? comparator : undefined}
|
||||
placeholder={t('Filter value (case sensitive)')}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -62,6 +62,8 @@ const StyledSyntaxContainer = styled.div`
|
||||
|
||||
const StyledThemedSyntaxHighlighter = styled(CodeSyntaxHighlighter)`
|
||||
flex: 1;
|
||||
height: ${({ theme }) => theme.sizeUnit * 26}px;
|
||||
margin-top: 0;
|
||||
`;
|
||||
|
||||
const StyledFooter = styled.div`
|
||||
@@ -163,7 +165,12 @@ const ViewQuery: FC<ViewQueryProps> = props => {
|
||||
) : (
|
||||
<StyledThemedSyntaxHighlighter
|
||||
language={language}
|
||||
customStyle={{ flex: 1, marginBottom: theme.sizeUnit * 3 }}
|
||||
customStyle={{
|
||||
flex: 1,
|
||||
marginBottom: theme.sizeUnit * 3,
|
||||
fontSize: theme.fontSize * 0.75,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{currentSQL}
|
||||
</StyledThemedSyntaxHighlighter>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ControlProps } from '@jsonforms/core';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
|
||||
import { MultiEnumControl } from './jsonFormsHelpers';
|
||||
|
||||
const baseProps = (overrides: Partial<ControlProps> = {}): ControlProps =>
|
||||
({
|
||||
label: 'Tags',
|
||||
path: 'tags',
|
||||
enabled: true,
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
enum: ['a', 'b', 'c'],
|
||||
'x-enumNames': ['Apple', 'Banana', 'Cherry'],
|
||||
},
|
||||
},
|
||||
uischema: { type: 'Control', scope: '#/properties/tags', options: {} },
|
||||
data: [],
|
||||
handleChange: jest.fn(),
|
||||
config: {},
|
||||
...overrides,
|
||||
}) as unknown as ControlProps;
|
||||
|
||||
test('renders enum labels from items.x-enumNames', async () => {
|
||||
render(<MultiEnumControl {...baseProps()} />);
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
expect(await screen.findByText('Apple')).toBeInTheDocument();
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cherry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('falls back to raw enum values when x-enumNames is absent', async () => {
|
||||
const props = baseProps({
|
||||
schema: { type: 'array', items: { enum: ['red', 'green'] } },
|
||||
});
|
||||
const { container } = render(<MultiEnumControl {...props} />);
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await screen.findAllByText('red');
|
||||
const options = container.ownerDocument.querySelectorAll(
|
||||
'.ant-select-item-option-content',
|
||||
);
|
||||
expect(Array.from(options).map(el => el.textContent)).toEqual([
|
||||
'red',
|
||||
'green',
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits the new array via handleChange when an option is picked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<MultiEnumControl {...baseProps({ handleChange })} />);
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByText('Banana'));
|
||||
expect(handleChange).toHaveBeenLastCalledWith('tags', ['b']);
|
||||
});
|
||||
|
||||
test('renders existing data as selected tags using x-enumNames labels', () => {
|
||||
render(<MultiEnumControl {...baseProps({ data: ['a', 'c'] })} />);
|
||||
// Selected items render in a hidden listbox with role=option,
|
||||
// but the tag text is the user-visible label.
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cherry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Banana')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows a loading state when config.refreshingSchema is true', () => {
|
||||
const { container } = render(
|
||||
<MultiEnumControl {...baseProps({ config: { refreshingSchema: true } })} />,
|
||||
);
|
||||
expect(container.querySelector('.ant-select-arrow-loading')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('treats non-array data as an empty selection without crashing', () => {
|
||||
render(<MultiEnumControl {...baseProps({ data: undefined })} />);
|
||||
// No tags rendered when data is missing
|
||||
expect(screen.queryByText('Apple')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Banana')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { JsonSchema } from '@jsonforms/core';
|
||||
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
|
||||
|
||||
import {
|
||||
areDependenciesSatisfied,
|
||||
@@ -24,8 +24,16 @@ import {
|
||||
buildUiSchema,
|
||||
getDynamicDependencies,
|
||||
serializeDependencyValues,
|
||||
multiEnumEntry,
|
||||
enumNamesEntry,
|
||||
} from './jsonFormsHelpers';
|
||||
|
||||
const control = {
|
||||
type: 'Control',
|
||||
scope: '#',
|
||||
} as unknown as UISchemaElement;
|
||||
const ctx = { rootSchema: {} as JsonSchema, config: {} };
|
||||
|
||||
test('areDependenciesSatisfied returns true for present dependency values', () => {
|
||||
expect(
|
||||
areDependenciesSatisfied(['database', 'schema'], {
|
||||
@@ -148,3 +156,38 @@ test('serializeDependencyValues is stable and sorted by key', () => {
|
||||
JSON.stringify({ database: 'analytics', warehouse: 'compute_wh' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('multiEnumEntry.tester matches array schemas with non-empty items.enum', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: { enum: ['a', 'b'] },
|
||||
} as unknown as JsonSchema;
|
||||
expect(multiEnumEntry.tester(control, schema, ctx)).toBe(35);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['empty items.enum', { type: 'array', items: { enum: [] } }],
|
||||
['items without enum', { type: 'array', items: { type: 'string' } }],
|
||||
['array without items', { type: 'array' }],
|
||||
['scalar enum', { type: 'string', enum: ['a', 'b'] }],
|
||||
])('multiEnumEntry.tester does not match %s', (_label, schema) => {
|
||||
expect(multiEnumEntry.tester(control, schema as JsonSchema, ctx)).toBe(-1);
|
||||
});
|
||||
|
||||
test('enumNamesEntry.tester matches scalar enum with x-enumNames', () => {
|
||||
const schema = {
|
||||
type: 'string',
|
||||
enum: ['a', 'b'],
|
||||
'x-enumNames': ['Alpha', 'Beta'],
|
||||
} as JsonSchema;
|
||||
expect(enumNamesEntry.tester(control, schema, ctx)).toBe(5);
|
||||
});
|
||||
|
||||
test('enumNamesEntry.tester does not match array schemas (multiEnum owns those)', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: { enum: ['a'], 'x-enumNames': ['Alpha'] },
|
||||
'x-enumNames': ['Alpha'],
|
||||
} as JsonSchema;
|
||||
expect(enumNamesEntry.tester(control, schema, ctx)).toBe(-1);
|
||||
});
|
||||
|
||||
@@ -252,25 +252,108 @@ function EnumNamesControl(props: ControlProps) {
|
||||
);
|
||||
}
|
||||
const EnumNamesRenderer = withJsonFormsControlProps(EnumNamesControl);
|
||||
const enumNamesEntry = {
|
||||
export const enumNamesEntry = {
|
||||
// Rank 5: higher than the default string renderer (2–3) so this fires
|
||||
// whenever x-enumNames is present, regardless of the underlying type.
|
||||
// Array-of-enum schemas are handled by ``multiEnumEntry`` below — this
|
||||
// renderer only targets scalar string/number controls.
|
||||
tester: rankWith(
|
||||
5,
|
||||
schemaMatches(s => {
|
||||
const names = (s as Record<string, unknown>)['x-enumNames'];
|
||||
return Array.isArray(names) && (names as unknown[]).length > 0;
|
||||
}),
|
||||
and(
|
||||
schemaMatches(s => {
|
||||
const names = (s as Record<string, unknown>)['x-enumNames'];
|
||||
return Array.isArray(names) && (names as unknown[]).length > 0;
|
||||
}),
|
||||
schemaMatches(s => (s as Record<string, unknown>)?.type !== 'array'),
|
||||
),
|
||||
),
|
||||
renderer: EnumNamesRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer for ``{type: 'array', items: {enum: [...]}}`` schemas. Renders
|
||||
* a single Antd Select with ``mode="multiple"`` (tag-style multi-select),
|
||||
* matching the natural expectation of a "pick several from a list" control.
|
||||
*
|
||||
* Without this, the default ``PrimitiveArrayControl`` from the upstream
|
||||
* library renders an "Add …" button that creates one single-select per
|
||||
* element — visually wrong for an enum multi-select and unable to display
|
||||
* ``items.x-enumNames`` labels.
|
||||
*
|
||||
* The renderer is dynamic-aware: when the host form is refreshing the
|
||||
* schema (e.g. compatible options narrowing as the user picks), the Select
|
||||
* shows a loading indicator without becoming disabled, so the user can
|
||||
* continue editing while options refresh.
|
||||
*/
|
||||
export function MultiEnumControl(props: ControlProps) {
|
||||
const { refreshingSchema } = props.config ?? {};
|
||||
const arraySchema = props.schema as Record<string, unknown>;
|
||||
const itemsSchema =
|
||||
(arraySchema.items as Record<string, unknown>) ??
|
||||
({} as Record<string, unknown>);
|
||||
|
||||
const enumValues = (itemsSchema.enum as unknown[]) ?? [];
|
||||
const enumNames =
|
||||
(itemsSchema['x-enumNames'] as string[]) ?? enumValues.map(String);
|
||||
|
||||
const options = enumValues.map((value, index) => ({
|
||||
value: value as string | number,
|
||||
label: enumNames[index] ?? String(value),
|
||||
}));
|
||||
|
||||
const value = Array.isArray(props.data) ? (props.data as unknown[]) : [];
|
||||
|
||||
const tooltip = (props.uischema?.options as Record<string, unknown>)
|
||||
?.tooltip as string | undefined;
|
||||
|
||||
return (
|
||||
<Form.Item label={props.label} tooltip={tooltip}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={value as (string | number)[]}
|
||||
onChange={next => props.handleChange(props.path, next)}
|
||||
options={options}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!props.enabled}
|
||||
loading={!!refreshingSchema}
|
||||
allowClear
|
||||
optionFilterProp="label"
|
||||
placeholder={
|
||||
(props.uischema?.options as Record<string, unknown>)
|
||||
?.placeholderText as string | undefined
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
const MultiEnumRenderer = withJsonFormsControlProps(MultiEnumControl);
|
||||
export const multiEnumEntry = {
|
||||
// Rank 35: must beat upstream ``PrimitiveArrayRenderer`` (rank 30) so an
|
||||
// ``array``/``items.enum`` schema renders as one Antd multi-select tag
|
||||
// box instead of the "Add" repeater pattern that PrimitiveArray uses.
|
||||
tester: rankWith(
|
||||
35,
|
||||
schemaMatches(s => {
|
||||
const schema = s as Record<string, unknown>;
|
||||
if (schema?.type !== 'array') return false;
|
||||
const items = schema.items as Record<string, unknown> | undefined;
|
||||
return (
|
||||
!!items &&
|
||||
Array.isArray(items.enum) &&
|
||||
(items.enum as unknown[]).length > 0
|
||||
);
|
||||
}),
|
||||
),
|
||||
renderer: MultiEnumRenderer,
|
||||
};
|
||||
|
||||
export const renderers = [
|
||||
...rendererRegistryEntries,
|
||||
passwordEntry,
|
||||
constEntry,
|
||||
readOnlyEntry,
|
||||
enumNamesEntry,
|
||||
multiEnumEntry,
|
||||
dynamicFieldEntry,
|
||||
];
|
||||
|
||||
|
||||
@@ -254,7 +254,10 @@ export default function AddSemanticViewModal({
|
||||
!schema?.properties ||
|
||||
Object.keys(schema.properties).length === 0
|
||||
) {
|
||||
// No runtime config needed — fetch views right away
|
||||
// Preserve top-level runtime metadata (e.g. x-singleView) even when
|
||||
// there are no form fields, then fetch views right away. Skip the
|
||||
// apply call entirely if the backend returned no schema at all.
|
||||
if (schema) applyRuntimeSchema(schema);
|
||||
fetchViews(uuid, {}, gen);
|
||||
} else {
|
||||
applyRuntimeSchema(schema);
|
||||
@@ -456,6 +459,31 @@ export default function AddSemanticViewModal({
|
||||
const viewsDisabled =
|
||||
loadingViews || (!loadingViews && availableViews.length === 0);
|
||||
|
||||
// When ``x-singleView: true`` the runtime form fully describes a single
|
||||
// semantic view (e.g. a MetricFlow cube). Hide the picker and auto-select
|
||||
// whatever ``get_semantic_views`` returned so the Add button can fire
|
||||
// without an extra user click.
|
||||
const singleViewMode =
|
||||
(runtimeSchema as Record<string, unknown> | null)?.['x-singleView'] ===
|
||||
true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleViewMode) return;
|
||||
const namesToAdd = availableViews
|
||||
.filter(v => !v.already_added)
|
||||
.map(v => v.name)
|
||||
.slice(0, 1);
|
||||
setSelectedViewNames(prev => {
|
||||
if (
|
||||
prev.length === namesToAdd.length &&
|
||||
prev.every((n, i) => n === namesToAdd[i])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return namesToAdd;
|
||||
});
|
||||
}, [singleViewMode, availableViews]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
show={show}
|
||||
@@ -511,8 +539,12 @@ export default function AddSemanticViewModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Semantic Views — always visible once a layer is selected */}
|
||||
{selectedLayerUuid && !loadingRuntime && (
|
||||
{/* Semantic Views — always visible once a layer is selected, unless
|
||||
the runtime schema declares ``x-singleView: true``: extensions
|
||||
(e.g. MetricFlow cubes) whose runtime form fully describes a
|
||||
single view set that flag so the picker disappears and the
|
||||
view is auto-selected when ``get_semantic_views`` returns it. */}
|
||||
{selectedLayerUuid && !loadingRuntime && !singleViewMode && (
|
||||
<ModalFormField label={t('Semantic Views')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic views')}
|
||||
|
||||
@@ -94,7 +94,7 @@ function UserListModal({
|
||||
} else {
|
||||
try {
|
||||
await createUser(values);
|
||||
addSuccessToast(t('The group has been created successfully.'));
|
||||
addSuccessToast(t('The user has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.CREATE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import config from './controlPanel';
|
||||
|
||||
type ControlConfig = {
|
||||
label?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
type ControlItem = {
|
||||
config: ControlConfig;
|
||||
} | null;
|
||||
|
||||
function collectFunctionProps(cfg: typeof config) {
|
||||
const fns: Array<() => unknown> = [];
|
||||
cfg.controlPanelSections.forEach(section => {
|
||||
section?.controlSetRows.forEach(row => {
|
||||
(row as ControlItem[]).forEach(item => {
|
||||
if (item && typeof item === 'object' && 'config' in item) {
|
||||
const { label, description } = item.config;
|
||||
if (typeof label === 'function') fns.push(label as () => unknown);
|
||||
if (typeof description === 'function')
|
||||
fns.push(description as () => unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return fns;
|
||||
}
|
||||
|
||||
test('Range controlPanel label and description functions return strings', () => {
|
||||
const fns = collectFunctionProps(config);
|
||||
expect(fns.length).toBeGreaterThan(0);
|
||||
fns.forEach(fn => {
|
||||
expect(typeof fn()).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -50,22 +50,21 @@ const config: ControlPanelConfig = {
|
||||
name: 'enableEmptyFilter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Filter value is required'),
|
||||
label: () => t('Filter value is required'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'User must select a value before applying the filter',
|
||||
),
|
||||
description: () =>
|
||||
t('User must select a value before applying the filter'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enableSingleValue',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Single value'),
|
||||
label: () => t('Single value'),
|
||||
default: SingleValueType.Exact,
|
||||
renderTrigger: true,
|
||||
description: t('Use only a single value.'),
|
||||
description: () => t('Use only a single value.'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user