Compare commits

..

2 Commits

Author SHA1 Message Date
Joe Li
37cba307fb test(dashboard): use typed getDatasetByName helper in native-filter url-key spec
Replace local findDatasetIdByName(page: any) with the existing typed
getDatasetByName(page: Page) helper (retry-wrapped apiGet, no new any).
Addresses review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:15:21 +00:00
Joe Li
db55967301 test(dashboard): migrate native filter URL key E2E to Playwright
Migrate the "nativefilter url param key" suite from the deprecated Cypress
tests to the Playwright framework. When a dashboard with native filters
loads, the filter bar publishes its data mask to the server-side
filter_state key-value store and stamps the returned key into the URL as
native_filters_key.

The migration builds the dashboard hermetically (one native filter + one
chart on birth_names) and strengthens the original URL-sniffing into a real
round-trip assertion: a POST mints the key, the key resolves server-side via
GET /api/v1/dashboard/<id>/filter_state/<key> (200 with the stored data
mask), and a reload reuses the same resolvable key.

The original suite's second case ("different key when page reloads") was
non-functional — it compared native_filters_key against a variable that was
declared but never assigned, so it asserted against undefined and passed
vacuously. The real backend contract reuses the key for a given
(session, tab, dashboard) via a contextual cache, so this test asserts the
true reuse behaviour instead of the inherited bug.

Adds DashboardPage.getNativeFiltersKey()/waitForNativeFiltersKey() helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:15:12 +00:00
322 changed files with 2915 additions and 11032 deletions

View File

@@ -32,6 +32,8 @@ runs:
elif [ "$INPUT_PYTHON_VERSION" = "next" ]; then
# currently disabled in GHA matrixes because of library compatibility issues
RESOLVED_VERSION="3.12"
elif [ "$INPUT_PYTHON_VERSION" = "previous" ]; then
RESOLVED_VERSION="3.10"
elif printf '%s' "$INPUT_PYTHON_VERSION" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
RESOLVED_VERSION="$INPUT_PYTHON_VERSION"
else

View File

@@ -11,15 +11,7 @@ updates:
- package-ecosystem: "npm"
ignore:
- dependency-name: "@rjsf/*"
# TODO: remove below entries once the application supports React >= 19.0.0
- dependency-name: "react"
update-types: ["version-update:semver-major"]
- dependency-name: "react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/react"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/react-dom"
update-types: ["version-update:semver-major"]
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
# Source: https://jestjs.io/blog#known-issues

View File

@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: "/language:${{matrix.language}}"

View File

@@ -31,7 +31,7 @@ jobs:
# token, which makes npm attempt token auth and skip the OIDC
# trusted-publishing exchange. With no .npmrc auth line, npm authenticates
# via OIDC against the default registry (registry.npmjs.org).
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-embedded-sdk/.nvmrc"
- run: npm ci

View File

@@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-embedded-sdk/.nvmrc"
registry-url: "https://registry.npmjs.org"

View File

@@ -7,11 +7,6 @@ on:
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
# Nightly full-tree sweep. Per-PR runs only lint changed files, so a change
# that invalidates an untouched file (e.g. a type change that breaks an
# importing test) can pass every PR yet leave master red. This catches that.
schedule:
- cron: "0 6 * * *"
permissions:
contents: read
@@ -30,16 +25,13 @@ jobs:
# Run the full version spread on push (master/release) and nightly,
# but only the current version on PRs — lint/format/type results
# rarely differ across patch versions, so 3x per PR is wasteful.
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
# Full history so we can diff a PR/push against its base commit to
# determine changed files (see "Determine changed files" below).
fetch-depth: 0
- name: Setup Python
uses: ./.github/actions/setup-backend/
@@ -53,7 +45,7 @@ jobs:
run: go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.14.2
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "superset-frontend/.nvmrc"
cache: "npm"
@@ -77,82 +69,19 @@ jobs:
restore-keys: |
pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-
- name: Determine changed files
- name: Get changed files
id: changed_files
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
# Scheduled runs check the whole tree (see the pre-commit step).
if [ "${EVENT_NAME}" = "schedule" ]; then
echo "mode=all" >> "$GITHUB_OUTPUT"
exit 0
fi
# Resolve the commit to diff against.
base=""
if [ "${EVENT_NAME}" = "pull_request" ]; then
base="${BASE_SHA}"
elif [ -n "${BEFORE_SHA:-}" ] && \
[ "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]; then
base="${BEFORE_SHA}"
fi
# Fail closed: if the diff base can't be resolved, check every file
# instead of silently checking nothing. Previously an empty file list
# made `pre-commit run --files` a no-op that still reported success,
# which let unlinted code reach master.
if [ -z "${base}" ] || ! git cat-file -e "${base}^{commit}" 2>/dev/null; then
echo "::notice::Could not resolve a diff base; falling back to --all-files."
echo "mode=all" >> "$GITHUB_OUTPUT"
exit 0
fi
# Files present in HEAD that changed since the base (drop deletions).
files="$(git diff --name-only --diff-filter=ACMRT "${base}...HEAD")"
if [ -z "${files}" ]; then
echo "mode=none" >> "$GITHUB_OUTPUT"
else
echo "mode=files" >> "$GITHUB_OUTPUT"
{
echo "files<<__CHANGED_FILES_EOF__"
echo "${files}"
echo "__CHANGED_FILES_EOF__"
} >> "$GITHUB_OUTPUT"
fi
uses: ./.github/actions/file-changes-action
with:
output: " "
- name: pre-commit
env:
MODE: ${{ steps.changed_files.outputs.mode }}
CHANGED_FILES: ${{ steps.changed_files.outputs.files }}
run: |
set +e # Don't exit immediately on failure
export SKIP=type-checking-frontend
case "${MODE}" in
all)
echo " Running pre-commit on all files."
pre-commit run --all-files
;;
files)
echo " Running pre-commit on changed files:"
echo "${CHANGED_FILES}"
# shellcheck disable=SC2086
pre-commit run --files ${CHANGED_FILES}
;;
none)
echo " No source files changed; nothing for pre-commit to check."
exit 0
;;
*)
echo "⚠️ Unrecognized changed-files mode '${MODE}'; checking all files."
pre-commit run --all-files
;;
esac
pre-commit run --files $CHANGED_FILES
PRE_COMMIT_EXIT_CODE=$?
git diff --quiet --exit-code
GIT_DIFF_EXIT_CODE=$?

View File

@@ -50,7 +50,7 @@ jobs:
- name: Install Node.js
if: env.HAS_TAGS
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-frontend/.nvmrc"

View File

@@ -117,7 +117,7 @@ jobs:
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20

View File

@@ -66,7 +66,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./docs/.nvmrc"
- name: Setup Python

View File

@@ -78,7 +78,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./docs/.nvmrc"
- name: yarn install
@@ -118,7 +118,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./docs/.nvmrc"
- name: yarn install

View File

@@ -128,7 +128,7 @@ jobs:
with:
run: testdata
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-frontend/.nvmrc"
cache: "npm"
@@ -238,7 +238,7 @@ jobs:
with:
run: playwright_testdata
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-frontend/.nvmrc"
cache: "npm"

View File

@@ -25,7 +25,7 @@ jobs:
matrix:
# Full version spread on push (master/release) + nightly; current only
# on PRs to cut runner cost (cross-version breaks are caught at merge).
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
defaults:
run:
working-directory: superset-extensions-cli

View File

@@ -114,7 +114,7 @@ jobs:
with:
run: playwright_testdata
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-frontend/.nvmrc"
cache: "npm"

View File

@@ -135,7 +135,7 @@ jobs:
matrix:
# Full version spread on push (master/release) + nightly; current only
# on PRs to cut runner cost (cross-version breaks are caught at merge).
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
SUPERSET_CONFIG: tests.integration_tests.superset_test_config

View File

@@ -50,7 +50,7 @@ jobs:
matrix:
# Full version spread on push (master/release) + nightly; current only
# on PRs to cut runner cost (cross-version breaks are caught at merge).
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as

View File

@@ -38,7 +38,7 @@ jobs:
- name: Setup Node.js
if: steps.check.outputs.frontend
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-frontend/.nvmrc"
cache: "npm"

View File

@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "./superset-frontend/.nvmrc"

3
.gitmodules vendored
View File

@@ -21,6 +21,9 @@
[submodule ".github/actions/pr-lint-action"]
path = .github/actions/pr-lint-action
url = https://github.com/morrisoncole/pr-lint-action
[submodule ".github/actions/file-changes-action"]
path = .github/actions/file-changes-action
url = https://github.com/trilom/file-changes-action
[submodule ".github/actions/cached-dependencies"]
path = .github/actions/cached-dependencies
url = https://github.com/apache-superset/cached-dependencies

View File

@@ -135,7 +135,7 @@ Superset sends an HTTP POST with `Content-Type: application/json`:
}
```
When a report includes file attachments (CSV, Excel, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
When a report includes file attachments (CSV, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
```
POST /webhook HTTP/1.1

View File

@@ -98,7 +98,7 @@
"globals": "^17.7.0",
"prettier": "^3.9.1",
"typescript": "~6.0.3",
"typescript-eslint": "^8.63.0",
"typescript-eslint": "^8.62.0",
"webpack": "^5.108.2"
},
"browserslist": {

View File

@@ -5504,21 +5504,32 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.63.0", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz#0d85d0ec1a28b0e35f484cc8220a8bf084019e71"
integrity sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==
"@typescript-eslint/eslint-plugin@8.62.1", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz#1736dcdca6cae3359d818456a47d18b674761f7f"
integrity sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.63.0"
"@typescript-eslint/type-utils" "8.63.0"
"@typescript-eslint/utils" "8.63.0"
"@typescript-eslint/visitor-keys" "8.63.0"
"@typescript-eslint/scope-manager" "8.62.1"
"@typescript-eslint/type-utils" "8.62.1"
"@typescript-eslint/utils" "8.62.1"
"@typescript-eslint/visitor-keys" "8.62.1"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.63.0", "@typescript-eslint/parser@^8.63.0":
"@typescript-eslint/parser@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.62.1.tgz#d3f7ba18f1bf78bfb7256fea021d1927b48e7080"
integrity sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==
dependencies:
"@typescript-eslint/scope-manager" "8.62.1"
"@typescript-eslint/types" "8.62.1"
"@typescript-eslint/typescript-estree" "8.62.1"
"@typescript-eslint/visitor-keys" "8.62.1"
debug "^4.4.3"
"@typescript-eslint/parser@^8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.63.0.tgz#2993338c379903e6afc72c3532ae6e15b97334d4"
integrity sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==
@@ -5529,6 +5540,15 @@
"@typescript-eslint/visitor-keys" "8.63.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.62.1.tgz#78d880eb1cf6859b5ec263d04f95403e9f90ae47"
integrity sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.62.1"
"@typescript-eslint/types" "^8.62.1"
debug "^4.4.3"
"@typescript-eslint/project-service@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.63.0.tgz#01a3d0550a860127444a9939749ab434591cd71a"
@@ -5538,6 +5558,14 @@
"@typescript-eslint/types" "^8.63.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz#7ee65e9a6eb3ccdc4816593a4ff38840306de88a"
integrity sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==
dependencies:
"@typescript-eslint/types" "8.62.1"
"@typescript-eslint/visitor-keys" "8.62.1"
"@typescript-eslint/scope-manager@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz#c9cf7ecd234f7ec346f62e5c7d28dfaf4d507b4d"
@@ -5546,6 +5574,11 @@
"@typescript-eslint/types" "8.63.0"
"@typescript-eslint/visitor-keys" "8.63.0"
"@typescript-eslint/tsconfig-utils@8.62.1", "@typescript-eslint/tsconfig-utils@^8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz#e2b5f24fe721044189cb7e81117c96d75979d627"
integrity sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==
"@typescript-eslint/tsconfig-utils@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz#f7e1cf9a029bb71f4027ffa4194ba82afb564cd3"
@@ -5556,27 +5589,47 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851"
integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==
"@typescript-eslint/type-utils@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz#9c00f362140186c588da86b3e10c212800b64b85"
integrity sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==
"@typescript-eslint/type-utils@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz#ebd30b13bacb13070917259a23309cf644121f9a"
integrity sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==
dependencies:
"@typescript-eslint/types" "8.63.0"
"@typescript-eslint/typescript-estree" "8.63.0"
"@typescript-eslint/utils" "8.63.0"
"@typescript-eslint/types" "8.62.1"
"@typescript-eslint/typescript-estree" "8.62.1"
"@typescript-eslint/utils" "8.62.1"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.1.tgz#c58be954e483b2fc98275374d5bcb40b99842dc1"
integrity sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==
"@typescript-eslint/types@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.63.0.tgz#6b32b0a5913520554d81a986acfffba2d32b6963"
integrity sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==
"@typescript-eslint/types@^8.63.0":
"@typescript-eslint/types@^8.62.1", "@typescript-eslint/types@^8.63.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b"
integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==
"@typescript-eslint/typescript-estree@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz#98c1bb17635d5b026b24193a8d29188ac64380ff"
integrity sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==
dependencies:
"@typescript-eslint/project-service" "8.62.1"
"@typescript-eslint/tsconfig-utils" "8.62.1"
"@typescript-eslint/types" "8.62.1"
"@typescript-eslint/visitor-keys" "8.62.1"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/typescript-estree@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz#a6f9c9cd290e98203ad29850b0d72529dc83be73"
@@ -5592,15 +5645,23 @@
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.63.0.tgz#b6a6c8aff1cebd1de4410b3a42b7ec9ba6703f23"
integrity sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==
"@typescript-eslint/utils@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.62.1.tgz#1622b75c7e6df308181dd0b44855dc4228da0457"
integrity sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.63.0"
"@typescript-eslint/types" "8.63.0"
"@typescript-eslint/typescript-estree" "8.63.0"
"@typescript-eslint/scope-manager" "8.62.1"
"@typescript-eslint/types" "8.62.1"
"@typescript-eslint/typescript-estree" "8.62.1"
"@typescript-eslint/visitor-keys@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz#499657d77ffafb8a99eb1d6c97847ca430234722"
integrity sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==
dependencies:
"@typescript-eslint/types" "8.62.1"
eslint-visitor-keys "^5.0.0"
"@typescript-eslint/visitor-keys@8.63.0":
version "8.63.0"
@@ -15272,15 +15333,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.63.0:
version "8.63.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.63.0.tgz#1c2b65c989572af7113fbae7f54c7ffab0fbeb12"
integrity sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==
typescript-eslint@^8.62.0:
version "8.62.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.62.1.tgz#eb93fd94d527aa04ec5b844fb0b4ada613cc7d3f"
integrity sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==
dependencies:
"@typescript-eslint/eslint-plugin" "8.63.0"
"@typescript-eslint/parser" "8.63.0"
"@typescript-eslint/typescript-estree" "8.63.0"
"@typescript-eslint/utils" "8.63.0"
"@typescript-eslint/eslint-plugin" "8.62.1"
"@typescript-eslint/parser" "8.62.1"
"@typescript-eslint/typescript-estree" "8.62.1"
"@typescript-eslint/utils" "8.62.1"
typescript@~6.0.3:
version "6.0.3"

View File

@@ -45,7 +45,7 @@ dependencies = [
# without the ``base.txt`` lock file (#40962).
"cachetools>=7.1.4, <8",
"celery>=5.6.3, <6.0.0",
"click>=8.4.2",
"click>=8.4.0",
"click-option-group",
"colorama",
"flask-cors>=6.0.5, <7.0",
@@ -130,7 +130,7 @@ bigquery = [
"sqlalchemy-bigquery>=1.17.0",
"google-cloud-bigquery>=3.42.1",
]
clickhouse = ["clickhouse-connect>=1.4.2, <2.0"]
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
d1 = [
@@ -155,7 +155,7 @@ elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
excel = ["xlrd>=2.0.2, <2.1"]
fastmcp = [
"fastmcp>=3.4.3,<4.0",
"fastmcp>=3.4.2,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.
@@ -165,7 +165,7 @@ firebird = ["sqlalchemy-firebird>=0.8.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.29.23", "sqlalchemy_hana==3.0.3"]
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==3.0.3"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
@@ -192,14 +192,14 @@ pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.61.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.338.0"]
trino = ["trino>=0.337.0"]
prophet = ["prophet>=1.1.6, <2"]
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
risingwave = ["sqlalchemy-risingwave"]
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
snowflake = ["snowflake-sqlalchemy>=1.10.2, <2"]
sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
sqlite = ["syntaqlite>=0.6.0,<0.7.0"]
spark = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7",
@@ -215,7 +215,7 @@ thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
netezza = ["nzalchemy>=11.0.2, < 11.2"]
starrocks = ["starrocks>=1.3.3, <2"]
doris = ["pydoris>=1.2.0, <2.0.0"]
doris = ["pydoris>=1.0.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1.2"]
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
development = [
@@ -244,7 +244,7 @@ development = [
"ruff",
"sqloxide",
"statsd",
"syntaqlite>=0.7.0,<0.8.0",
"syntaqlite>=0.6.0,<0.7.0",
]
[project.urls]

View File

@@ -40,7 +40,7 @@ filterwarnings =
error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning
# error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
# error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning
error:The legacy calling style of select\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning

View File

@@ -60,7 +60,7 @@ cffi==2.0.0
# pynacl
charset-normalizer==3.4.2
# via requests
click==8.4.2
click==8.4.1
# via
# apache-superset (pyproject.toml)
# celery

View File

@@ -130,7 +130,7 @@ charset-normalizer==3.4.2
# via
# -c requirements/base-constraint.txt
# requests
click==8.4.2
click==8.4.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -238,9 +238,9 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp-slim
fastmcp==3.4.4
fastmcp==3.4.2
# via apache-superset
fastmcp-slim==3.4.4
fastmcp-slim==3.4.2
# via fastmcp
filelock==3.20.3
# via
@@ -1017,7 +1017,7 @@ starlette==1.3.1
# mcp
statsd==4.0.1
# via apache-superset
syntaqlite==0.7.1
syntaqlite==0.6.0
# via apache-superset
tabulate==0.10.0
# via
@@ -1033,7 +1033,7 @@ tqdm==4.67.1
# via
# cmdstanpy
# prophet
trino==0.338.0
trino==0.337.0
# via apache-superset
trio==0.33.0
# via

View File

@@ -138,22 +138,6 @@ def _lang_name(code: str) -> str:
return LANGUAGE_NAMES.get(code, code)
# An ISO 639-1/639-2 code, optionally followed by an ISO 3166 region
# (``_BR``, two uppercase) or an ISO 15924 script (``_Latn``, titlecase). The
# script subtag matters for catalogs like ``sr_Latn`` (Serbian in Latin script);
# without it the code was rejected and the catalog silently skipped.
_LANG_CODE_RE: re.Pattern[str] = re.compile(r"[a-z]{2,3}(_([A-Z]{2}|[A-Z][a-z]{3}))?")
def _is_valid_lang_code(lang: str) -> bool:
"""Validate a language code before it lands, unsanitized, in a filesystem path.
Guards against path traversal while allowing the region and script subtags
Superset actually ships (e.g. ``pt_BR``, ``sr_Latn``).
"""
return bool(_LANG_CODE_RE.fullmatch(lang))
def _plural_key(msgid: str, msgid_plural: str) -> str:
"""Build the translation index key used for pluralized entries."""
return f"{msgid}\x00{msgid_plural}"
@@ -683,11 +667,13 @@ def backfill(
mark_fuzzy: bool = True,
) -> None:
"""Backfill missing translations in the target language's .po file."""
if not _is_valid_lang_code(lang):
# 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 a _<REGION> or _<Script> "
"subtag, e.g. 'fr', 'pt_BR', or 'sr_Latn')",
"(expected ISO 639 code, optionally with _<REGION>, e.g. 'fr' or 'pt_BR')",
file=sys.stderr,
)
sys.exit(1)

View File

@@ -17,38 +17,45 @@
* under the License.
*/
import { useState, useCallback, ReactNode } from 'react';
import { Component, ReactNode } from 'react';
export type Props = {
children: ReactNode;
expandableWhat?: string;
};
export default function Expandable({ children, expandableWhat }: Props) {
const [open, setOpen] = useState(false);
type State = {
open: boolean;
};
const handleToggle = useCallback(() => {
setOpen(prevOpen => !prevOpen);
}, []);
export default class Expandable extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { open: false };
this.handleToggle = this.handleToggle.bind(this);
}
const label = expandableWhat
? `${open ? 'Hide' : 'Show'} ${expandableWhat}`
: open
? 'Hide'
: 'Show';
handleToggle() {
this.setState(({ open }) => ({ open: !open }));
}
return (
<div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleToggle}
>
{label}
</button>
<br />
<br />
{open ? children : null}
</div>
);
render() {
const { open } = this.state;
const { children, expandableWhat } = this.props;
return (
<div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={this.handleToggle}
>
{`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
</button>
<br />
<br />
{open ? children : null}
</div>
);
}
}

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { Component, ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import {
SupersetClient,
@@ -36,6 +36,12 @@ export type Props = {
postPayload?: string;
};
type State = {
didVerify: boolean;
error?: Error | SupersetApiError;
payload?: object;
};
export const renderError = (error: Error) => (
<div>
The following error occurred, make sure you have <br />
@@ -48,37 +54,29 @@ export const renderError = (error: Error) => (
</div>
);
export default function VerifyCORS({
children,
endpoint,
host,
method,
postPayload,
}: Props): JSX.Element {
const [didVerify, setDidVerify] = useState(false);
const [error, setError] = useState<Error | SupersetApiError | undefined>(
undefined,
);
const [payload, setPayload] = useState<object | undefined>(undefined);
export default class VerifyCORS extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { didVerify: false };
this.handleVerify = this.handleVerify.bind(this);
}
const prevPropsRef = useRef({ endpoint, host, postPayload, method });
useEffect(() => {
const prevProps = prevPropsRef.current;
componentDidUpdate(prevProps: Props) {
const { endpoint, host, postPayload, method } = this.props;
if (
(didVerify || error) &&
(this.state.didVerify || this.state.error) &&
(prevProps.endpoint !== endpoint ||
prevProps.host !== host ||
prevProps.postPayload !== postPayload ||
prevProps.method !== method)
) {
setDidVerify(false);
setError(undefined);
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ didVerify: false, error: undefined });
}
prevPropsRef.current = { endpoint, host, postPayload, method };
}, [endpoint, host, postPayload, method, didVerify, error]);
}
const handleVerify = useCallback(() => {
handleVerify() {
const { endpoint, host, postPayload, method } = this.props;
SupersetClient.reset();
SupersetClient.configure({
credentials: 'include',
@@ -96,40 +94,43 @@ export default function VerifyCORS({
}
return { error: 'Must provide valid endpoint and payload.' };
})
.then(result => {
setDidVerify(true);
setError(undefined);
setPayload(result);
})
.catch(err => setError(err));
}, [endpoint, host, method, postPayload]);
.then(result =>
this.setState({ didVerify: true, error: undefined, payload: result }),
)
.catch(error => this.setState({ error }));
}
return didVerify ? (
<>{children({ payload })}</>
) : (
<div className="row">
<div className="col-md-10">
This example requires CORS requests from this domain. <br />
<br />
1) enable CORS requests in your Superset App from{' '}
{`${window.location.origin}`}
<br />
2) configure your Superset App host name below <br />
3) click below to verify authentication. You may debug CORS further
using the `@superset-ui/connection` story. <br />
<br />
<Button type="primary" size="small" onClick={handleVerify}>
{t('Verify')}
</Button>
<br />
<br />
</div>
render() {
const { didVerify, error, payload } = this.state;
const { children } = this.props;
{error && (
<div className="col-md-8">
<ErrorMessage error={error} />
return didVerify ? (
children({ payload })
) : (
<div className="row">
<div className="col-md-10">
This example requires CORS requests from this domain. <br />
<br />
1) enable CORS requests in your Superset App from{' '}
{`${window.location.origin}`}
<br />
2) configure your Superset App host name below <br />
3) click below to verify authentication. You may debug CORS further
using the `@superset-ui/connection` story. <br />
<br />
<Button type="primary" size="small" onClick={this.handleVerify}>
{t('Verify')}
</Button>
<br />
<br />
</div>
)}
</div>
);
{error && (
<div className="col-md-8">
<ErrorMessage error={error} />
</div>
)}
</div>
);
}
}

View File

@@ -201,7 +201,7 @@
"jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }],
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/autocomplete-valid": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/control-has-associated-label": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/html-has-lang": "error",

View File

@@ -202,7 +202,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.1.1",
"@types/node": "^26.1.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -215,7 +215,7 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
@@ -12266,9 +12266,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -12726,17 +12726,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz",
"integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
"integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/type-utils": "8.63.0",
"@typescript-eslint/utils": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/type-utils": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -12749,7 +12749,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.63.0",
"@typescript-eslint/parser": "^8.62.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -12789,7 +12789,7 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
@@ -12811,21 +12811,7 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
@@ -12843,7 +12829,7 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
@@ -12860,16 +12846,199 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz",
"integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.63.0",
"@typescript-eslint/tsconfig-utils": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0",
"@typescript-eslint/utils": "8.63.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
"integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.62.1",
"@typescript-eslint/types": "^8.62.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
"integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
"integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
"integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -12886,9 +13055,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12900,16 +13069,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
"integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.63.0",
"@typescript-eslint/tsconfig-utils": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"@typescript-eslint/project-service": "8.62.1",
"@typescript-eslint/tsconfig-utils": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -12967,16 +13136,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
"integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0"
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -12991,13 +13160,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
"integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/types": "8.62.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -42324,9 +42493,9 @@
}
},
"node_modules/websocket-driver": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -43691,7 +43860,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^26.1.1",
"@types/node": "^26.1.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -287,7 +287,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.1.1",
"@types/node": "^26.1.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -300,7 +300,7 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",

View File

@@ -78,7 +78,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^26.1.1",
"@types/node": "^26.1.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent, fireEvent } from '@superset-ui/core/spec';
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Icons } from '@superset-ui/core/components/Icons';
import { ActionButton } from '.';
@@ -45,18 +45,6 @@ test('calls onClick when clicked', async () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
test('calls onClick when activated with the keyboard', () => {
const onClick = jest.fn();
render(<ActionButton {...defaultProps} onClick={onClick} />);
const button = screen.getByRole('button');
fireEvent.keyDown(button, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(button, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(2);
});
test('renders with tooltip when tooltip prop is provided', async () => {
const tooltipText = 'This is a tooltip';
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { handleKeyboardActivation } from '../../utils';
import type { ReactElement, ReactNode } from 'react';
import { Tooltip, type TooltipPlacement } from '@superset-ui/core/components';
import { css, useTheme } from '@apache-superset/core/theme';
@@ -56,7 +55,6 @@ export const ActionButton = ({
className="action-button"
data-test={label}
onClick={onClick}
onKeyDown={onClick ? handleKeyboardActivation(onClick) : undefined}
>
{icon}
</span>

View File

@@ -16,13 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '../../utils';
import {
forwardRef,
ForwardedRef,
useState,
ReactNode,
SyntheticEvent,
MouseEvent,
} from 'react';
import { Button } from '../Button';
@@ -97,7 +96,7 @@ export const ModalTrigger = forwardRef(
onExit?.();
};
const open = (e: SyntheticEvent) => {
const open = (e: MouseEvent) => {
e.preventDefault();
beforeOpen?.();
setShowModal(true);
@@ -127,13 +126,7 @@ export const ModalTrigger = forwardRef(
</Button>
)}
{!isButton && (
<div
data-test="span-modal-trigger"
onClick={open}
onKeyDown={handleKeyboardActivation(open)}
role="button"
tabIndex={0}
>
<div data-test="span-modal-trigger" onClick={open} role="button">
{triggerNode}
</div>
)}

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '../../utils';
import { ReactNode, SyntheticEvent } from 'react';
import { MouseEventHandler, ReactNode } from 'react';
import { css, useTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Tooltip } from '../Tooltip';
@@ -25,10 +24,7 @@ import { Tooltip } from '../Tooltip';
export interface PopoverSectionProps {
title: string;
isSelected?: boolean;
// `SyntheticEvent` (rather than `MouseEventHandler`) so the same callback
// can be reused as the keyboard-activation handler via
// `handleKeyboardActivation`, which invokes it with a `KeyboardEvent`.
onSelect?: (event: SyntheticEvent) => void;
onSelect?: MouseEventHandler<HTMLDivElement>;
info?: string;
children?: ReactNode;
}
@@ -52,7 +48,6 @@ export default function PopoverSection({
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={onSelect ? handleKeyboardActivation(onSelect) : undefined}
css={css`
display: flex;
align-items: center;

View File

@@ -17,108 +17,126 @@
* under the License.
*/
import { useState, useCallback } from 'react';
import { PureComponent } from 'react';
import { formatNumber } from '@superset-ui/core';
const testValues: (number | null | undefined)[] = [
987654321,
12345.6789,
3000,
400.14,
70.00002,
1,
0,
-1,
-70.00002,
-400.14,
-3000,
-12345.6789,
-987654321,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
NaN,
null,
undefined,
];
interface NumberFormatValidatorState {
formatString: string;
testValues: (number | null | undefined)[];
}
function NumberFormatValidator() {
const [formatString, setFormatString] = useState('.3~s');
class NumberFormatValidator extends PureComponent<
Record<string, never>,
NumberFormatValidatorState
> {
state: NumberFormatValidatorState = {
formatString: '.3~s',
testValues: [
987654321,
12345.6789,
3000,
400.14,
70.00002,
1,
0,
-1,
-70.00002,
-400.14,
-3000,
-12345.6789,
-987654321,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
NaN,
null,
undefined,
],
};
const handleFormatChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFormatString(event.target.value);
},
[],
);
constructor(props: Record<string, never>) {
super(props);
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/number-format</code> package enriches{' '}
<code>d3-format</code>
to handle invalid formats as well as edge case values. Use the
validator below to preview outputs from the specified format string.
See
<a
href="https://github.com/d3/d3-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Format Reference
</a>
for how to write a D3 format string.
</p>
</div>
</div>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={handleFormatChange}
/>
</label>
</div>
this.handleFormatChange = this.handleFormatChange.bind(this);
}
handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
this.setState({
formatString: event.target.value,
});
}
render() {
const { formatString, testValues } = this.state;
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/number-format</code> package enriches{' '}
<code>d3-format</code>
to handle invalid formats as well as edge case values. Use the
validator below to preview outputs from the specified format
string. See
<a
href="https://github.com/d3/d3-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Format Reference
</a>
for how to write a D3 format string.
</p>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (number)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{`${v}`}</code>
</td>
<td>
<code>&quot;{formatNumber(formatString, v)}&quot;</code>
</td>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={this.handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (number)</th>
<th>Formatted output (string)</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{`${v}`}</code>
</td>
<td>
<code>&quot;{formatNumber(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
);
}
}
export default {

View File

@@ -17,96 +17,115 @@
* under the License.
*/
import { useState, useCallback } from 'react';
import { PureComponent } from 'react';
import { formatTime } from '@superset-ui/core';
const testValues: (Date | number | null | undefined)[] = [
new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
0,
null,
undefined,
];
interface TimeFormatValidatorState {
formatString: string;
testValues: (Date | number | null | undefined)[];
}
function TimeFormatValidator() {
const [formatString, setFormatString] = useState('%Y-%m-%d %H:%M:%S');
class TimeFormatValidator extends PureComponent<
Record<string, never>,
TimeFormatValidatorState
> {
state: TimeFormatValidatorState = {
formatString: '%Y-%m-%d %H:%M:%S',
testValues: [
new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
0,
null,
undefined,
],
};
const handleFormatChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFormatString(event.target.value);
},
[],
);
constructor(props: Record<string, never>) {
super(props);
this.handleFormatChange = this.handleFormatChange.bind(this);
}
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/time-format</code> package enriches
<code>d3-time-format</code> to handle invalid formats as well as
edge case values. Use the validator below to preview outputs from
the specified format string. See &nbsp;
<a
href="https://github.com/d3/d3-time-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Time Format Reference
</a>
&nbsp;for how to write a D3 time format string.
</p>
</div>
</div>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 time format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={handleFormatChange}
/>
</label>
</div>
handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
this.setState({
formatString: event.target.value,
});
}
render() {
const { formatString, testValues } = this.state;
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/time-format</code> package enriches
<code>d3-time-format</code> to handle invalid formats as well as
edge case values. Use the validator below to preview outputs from
the specified format string. See &nbsp;
<a
href="https://github.com/d3/d3-time-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Time Format Reference
</a>
&nbsp;for how to write a D3 time format string.
</p>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (time)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{v instanceof Date ? v.toUTCString() : `${v}`}</code>
</td>
<td>
<code>&quot;{formatTime(formatString, v)}&quot;</code>
</td>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 time format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={this.handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (time)</th>
<th>Formatted output (string)</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>
{v instanceof Date ? v.toUTCString() : `${v}`}
</code>
</td>
<td>
<code>&quot;{formatTime(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
);
}
}
export default {

View File

@@ -1,62 +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 { KeyboardEvent } from 'react';
import { handleKeyboardActivation } from './handleKeyboardActivation';
const makeEvent = (key: string, repeat = false) => {
const preventDefault = jest.fn();
return {
event: { key, repeat, preventDefault } as unknown as KeyboardEvent,
preventDefault,
};
};
test('invokes the callback and prevents default on Enter', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent('Enter');
handleKeyboardActivation(callback)(event);
expect(callback).toHaveBeenCalledWith(event);
expect(preventDefault).toHaveBeenCalled();
});
test('invokes the callback and prevents default on Space', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent(' ');
handleKeyboardActivation(callback)(event);
expect(callback).toHaveBeenCalledWith(event);
expect(preventDefault).toHaveBeenCalled();
});
test('ignores other keys', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent('a');
handleKeyboardActivation(callback)(event);
expect(callback).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
test('ignores auto-repeat keydown events fired while a key is held', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent('Enter', true);
handleKeyboardActivation(callback)(event);
expect(callback).not.toHaveBeenCalled();
// preventDefault still fires on the repeat event itself, matching the
// non-repeat Enter case above.
expect(preventDefault).toHaveBeenCalled();
});

View File

@@ -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 { KeyboardEvent } from 'react';
/**
* Builds an `onKeyDown` handler that invokes `callback` when the user presses
* Enter or Space, mirroring a click for keyboard users. Pair it with an
* element's `onClick` so `role="button"` (or similar) controls are operable
* from the keyboard, satisfying `jsx-a11y/click-events-have-key-events`.
*
* <div role="button" onClick={handleClick}
* onKeyDown={handleKeyboardActivation(handleClick)} />
*/
export function handleKeyboardActivation(
callback: (event: KeyboardEvent) => void,
) {
return (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
// Prevent the page from scrolling on Space and stop any duplicate
// default activation on Enter.
event.preventDefault();
// Ignore auto-repeat keydown events fired while the key is held, so
// a long press activates the callback once, matching a mouse click.
if (event.repeat) {
return;
}
callback(event);
}
};
}
export default handleKeyboardActivation;

View File

@@ -27,7 +27,6 @@ export { default as makeSingleton } from './makeSingleton';
export { default as promiseTimeout } from './promiseTimeout';
export { default as removeDuplicates } from './removeDuplicates';
export { default as withLabel } from './withLabel';
export { handleKeyboardActivation } from './handleKeyboardActivation';
export { lruCache } from './lruCache';
export { getSelectedText } from './getSelectedText';
export * from './featureFlags';

View File

@@ -144,6 +144,34 @@ export class DashboardPage {
await applyButton.click();
}
/**
* Read the `native_filters_key` query param from the current dashboard URL,
* or null if absent. This key references the server-side filter_state entry
* the native filter bar creates when it publishes its data mask.
*/
getNativeFiltersKey(): string | null {
return new URL(this.page.url()).searchParams.get('native_filters_key');
}
/**
* Wait until the native filter bar has published its state to the backend and
* the resulting `native_filters_key` appears in the URL, then return it.
*/
async waitForNativeFiltersKey(options?: { timeout?: number }): Promise<string> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
await this.page.waitForFunction(
() =>
new URLSearchParams(window.location.search).has('native_filters_key'),
undefined,
{ timeout },
);
const key = this.getNativeFiltersKey();
if (!key) {
throw new Error('native_filters_key not found in URL after publish');
}
return key;
}
/**
* Open the dashboard header actions menu (three-dot menu)
*/

View File

@@ -0,0 +1,223 @@
/**
* 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.
*/
/**
* E2E migration of the Cypress "nativefilter url param key" suite
* (dashboard/key_value.test.ts).
*
* When a dashboard with native filters loads, the filter bar publishes its data
* mask to the backend `filter_state` key-value store and stamps the returned
* key into the URL as `native_filters_key`. The original suite only sniffed the
* URL (the key is a string; it differs across visits). That is genuinely a
* full-stack behaviour — the key is minted by a real server round-trip and
* persisted server-side — so it is migrated here, but strengthened to assert the
* round-trip rather than just the URL shape:
*
* 1. A POST /api/v1/dashboard/<id>/filter_state mints the key, and that key is
* what lands in the URL.
* 2. The key resolves server-side: GET /api/v1/dashboard/<id>/filter_state/<key>
* returns the stored data mask (200). A client-only token would not resolve.
* 3. Reloading reuses the same resolvable key for the session/tab.
*
* The original suite's second case ("should have different key when page
* reloads") was non-functional: it compared `native_filters_key` against an
* `initialFilterKey` variable that was declared but never assigned, so it
* asserted against `undefined` and passed vacuously. The real backend contract
* is the opposite — CreateFilterStateCommand reuses the existing key for a given
* (session, tab, dashboard) via a contextual cache — so this migration asserts
* the true behaviour (reuse) instead of the bug it inherited.
*
* The dashboard is built hermetically (one native filter + one chart on
* birth_names), replacing the original's dependency on the seeded world_health
* dashboard (whose example charts are flaky under load).
*
* CI green => the filter bar minted a persisted, server-resolvable key and
* reloading reused that same resolvable key.
* CI red => no key was published, or the key did not resolve server-side.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
const FILTER_COLUMN = 'gender';
testWithAssets(
'native filter bar mints a persisted, server-resolvable filter_state key and reuses it on reload',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
// A single chart for the native filter to target.
const chartParams = {
datasource: `${datasetId}__table`,
viz_type: 'big_number_total',
metric: 'count',
adhoc_filters: [],
};
const chartResp = await apiPost(page, 'api/v1/chart/', {
slice_name: `nf_key_${Date.now()}`,
viz_type: 'big_number_total',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(chartParams),
});
expect(chartResp.ok()).toBe(true);
const chart = await chartResp.json();
const chartId: number = chart.id ?? chart.result?.id;
testAssets.trackChart(chartId);
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: { chartId, width: 6, height: 50, sliceName: 'nf_key' },
},
};
const jsonMetadata = {
native_filter_configuration: [
{
id: filterId,
name: 'Gender',
filterType: 'filter_select',
type: 'NATIVE_FILTER',
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
controlValues: {
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: false,
inverseSelection: false,
searchAllOptions: false,
},
defaultDataMask: { filterState: {}, extraFormData: {} },
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
],
chart_configuration: {},
cross_filters_enabled: false,
global_chart_configuration: {
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
};
const dashResp = await apiPostDashboard(page, {
dashboard_title: `nf_key_${Date.now()}`,
published: true,
position_json: JSON.stringify(positionJson),
json_metadata: JSON.stringify(jsonMetadata),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
testAssets.trackDashboard(dashboardId);
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
dashboards: [dashboardId],
});
expect(linkResp.ok()).toBe(true);
const dashboard = new DashboardPage(page);
// Confirm the key resolves to a stored data mask via the backend
// filter_state GET endpoint — proving it is a real server-side entry, not a
// client token. A client-only token would not resolve.
const assertKeyResolves = async (key: string) => {
const stateResp = await page.request.get(
`api/v1/dashboard/${dashboardId}/filter_state/${key}`,
);
expect(
stateResp.status(),
`filter_state key ${key} should resolve server-side`,
).toBe(200);
const stateBody = await stateResp.json();
// The stored value is the serialized data mask (valid JSON).
expect(
() => JSON.parse(stateBody.value),
`filter_state key ${key} should carry a stored data mask`,
).not.toThrow();
};
// The filter bar mints the key via a POST to filter_state on load.
let createPosted = false;
page.on('response', response => {
const req = response.request();
if (
req.method() === 'POST' &&
/\/api\/v1\/dashboard\/\d+\/filter_state(\?|$)/.test(response.url())
) {
createPosted = true;
}
});
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
const firstKey = await dashboard.waitForNativeFiltersKey();
expect(firstKey).toEqual(expect.any(String));
expect(firstKey.length).toBeGreaterThan(0);
// The key was minted by a real create round-trip, not invented client-side.
expect(
createPosted,
'a POST to filter_state should mint the key on load',
).toBe(true);
await assertKeyResolves(firstKey);
// Reload: the backend reuses the existing key for this (session, tab,
// dashboard), and it still resolves server-side.
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
const reloadKey = await dashboard.waitForNativeFiltersKey();
expect(
reloadKey,
'reloading should reuse the same filter_state key for the session/tab',
).toEqual(firstKey);
await assertKeyResolves(reloadKey);
},
);

View File

@@ -19,8 +19,7 @@
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { useRef, useState, useEffect, SyntheticEvent } from 'react';
import { useRef, useState, useEffect } from 'react';
import { t } from '@apache-superset/core/translation';
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
import { Column } from '@superset-ui/core/components/ThemedAgGridReact';
@@ -187,10 +186,7 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
return undefined;
}, [lastFilteredColumn, colId, lastFilteredInputPosition]);
// `SyntheticEvent` (rather than `MouseEvent`) so this callback can also be
// used as the keyboard-activation handler via `handleKeyboardActivation`,
// which invokes it with a `KeyboardEvent`.
const handleMenuClick = (e: SyntheticEvent) => {
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuVisible(!isMenuVisible);
};
@@ -205,35 +201,17 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
const menuContent = (
<MenuContainer>
{shouldShowAsc && (
<div
role="button"
tabIndex={0}
onClick={() => applySort('asc')}
onKeyDown={handleKeyboardActivation(() => applySort('asc'))}
className="menu-item"
>
<div onClick={() => applySort('asc')} className="menu-item">
<ArrowUpOutlined /> {t('Sort Ascending')}
</div>
)}
{shouldShowDesc && (
<div
role="button"
tabIndex={0}
onClick={() => applySort('desc')}
onKeyDown={handleKeyboardActivation(() => applySort('desc'))}
className="menu-item"
>
<div onClick={() => applySort('desc')} className="menu-item">
<ArrowDownOutlined /> {t('Sort Descending')}
</div>
)}
{currentSort && currentSort?.colId === colId && (
<div
role="button"
tabIndex={0}
onClick={clearSort}
onKeyDown={handleKeyboardActivation(clearSort)}
className="menu-item"
>
<div onClick={clearSort} className="menu-item">
<span style={{ fontSize: 16 }}></span> {t('Clear Sort')}
</div>
)}
@@ -269,13 +247,7 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
isOpen={isMenuVisible}
onClose={() => setMenuVisible(false)}
>
<div
role="button"
tabIndex={0}
className="three-dots-menu"
onClick={handleMenuClick}
onKeyDown={handleKeyboardActivation(handleMenuClick)}
>
<div className="three-dots-menu" onClick={handleMenuClick}>
<KebabMenu />
</div>
</CustomPopover>

View File

@@ -75,12 +75,8 @@ export default function EchartsMixedTimeseries({
return {
dataMask: {
extraFormData: {
// An empty lookup result means the clicked series could not be
// resolved through the label map; emitting column filters anyway
// would produce bogus `IS NULL` clauses (an empty array makes the
// `every` below vacuously true), so clear the filters instead.
filters:
values.length === 0 || groupbyValues.length === 0
values.length === 0
? []
: currentGroupBy.map((col, idx) => {
const val: DataRecordValue[] = groupbyValues.map(v => {
@@ -152,11 +148,10 @@ export default function EchartsMixedTimeseries({
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
const isFirst = isFirstQuery(seriesIndex);
const currentGroupBy = isFirst ? formData.groupby : formData.groupbyB;
const seriesValues = (isFirst ? labelMap : labelMapB)[seriesName] || [];
// Label map values may carry metric/offset labels ahead of the
// dimension values — anchor from the tail, like getCrossFilterDataMask.
const metricsCount = seriesValues.length - currentGroupBy.length;
const values = [
...(eventParams.name ? [eventParams.name] : []),
...((isFirst ? labelMap : labelMapB)[eventParams.seriesName] || []),
];
if (data && xAxis.type === AxisType.Time) {
drillToDetailFilters.push({
col:
@@ -169,39 +164,31 @@ export default function EchartsMixedTimeseries({
formattedVal: xValueFormatter(data[0]),
});
}
if (
data &&
xAxis.type === AxisType.Category &&
eventParams.name != null
) {
[
...(data && xAxis.type === AxisType.Category ? [xAxis.label] : []),
...(isFirst ? formData.groupby : formData.groupbyB),
].forEach((dimension, i) =>
drillToDetailFilters.push({
col: xAxis.label,
col: dimension,
op: '==',
val: eventParams.name,
formattedVal: String(eventParams.name),
});
}
if (metricsCount >= 0) {
currentGroupBy.forEach((dimension, i) => {
const value = seriesValues[metricsCount + i];
drillToDetailFilters.push({
col: dimension,
op: '==',
val: value,
formattedVal: String(value),
});
val: values[i],
formattedVal: String(values[i]),
}),
);
[...(isFirst ? formData.groupby : formData.groupbyB)].forEach(
(dimension, i) =>
drillByFilters.push({
col: dimension,
op: '==',
val: value,
formattedVal: formatSeriesName(value, {
val: values[i],
formattedVal: formatSeriesName(values[i], {
timeFormatter: getTimeFormatter(formData.dateFormat),
numberFormatter: getNumberFormatter(formData.numberFormat),
coltype: coltypeMapping?.[getColumnLabel(dimension)],
}),
});
});
}
}),
);
const hasCrossFilter =
(isFirst && groupby.length > 0) || (!isFirst && groupbyB.length > 0);

View File

@@ -146,14 +146,10 @@ export default function transformProps(
columnFormats = {},
currencyCodeColumn,
} = datasource;
// "raw" because these are keyed by the backend column labels; the maps
// returned to the component are re-keyed by the rendered series names below.
const { label_map: rawLabelMap, detected_currency: backendDetectedCurrency } =
const { label_map: labelMap, detected_currency: backendDetectedCurrency } =
queriesData[0] as TimeseriesChartDataResponseResult;
const {
label_map: rawLabelMapB,
detected_currency: backendDetectedCurrencyB,
} = queriesData[1] as TimeseriesChartDataResponseResult;
const { label_map: labelMapB, detected_currency: backendDetectedCurrencyB } =
queriesData[1] as TimeseriesChartDataResponseResult;
const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[];
const data2 = (queriesData[1].data || []) as TimeseriesDataRecord[];
const annotationData = getAnnotationData(chartProps);
@@ -442,16 +438,6 @@ export default function transformProps(
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
// The rendered ECharts series names are display names that can diverge from
// the backend `label_map` keys: the metric display name is prepended when
// dimensions are present, query identifiers may be appended, and verbose
// names replace the raw column labels. Cross-filtering and drill lookups in
// EchartsMixedTimeseries resolve the clicked series name through the label
// map, so expose maps re-keyed by the rendered series names to keep those
// lookups working (#41622).
const displayLabelMap: Record<string, string[]> = {};
const displayLabelMapB: Record<string, string[]> = {};
rawSeriesA.forEach(entry => {
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
@@ -471,19 +457,13 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
}
const labelMapValues = rawLabelMap?.[seriesName];
if (labelMapValues) {
displayLabelMap[displayName] = labelMapValues;
}
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndex);
const seriesFormatter = getFormatter(
axisFormatterConfig.customFormatters,
axisFormatterConfig.formatter,
metrics,
labelMapValues?.[0],
labelMap?.[seriesName]?.[0],
!!contributionMode,
);
@@ -534,6 +514,7 @@ export default function transformProps(
rawSeriesB.forEach(entry => {
const entryName = String(entry.name || '');
const seriesEntry = inverted[entryName] || entryName;
const seriesName = `${seriesEntry} (1)`;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
let displayName: string;
@@ -550,19 +531,13 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
}
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
if (labelMapValuesB) {
displayLabelMapB[displayName] = labelMapValuesB;
}
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndexB);
const seriesFormatter = getFormatter(
axisFormatterConfig.customFormatters,
axisFormatterConfig.formatter,
metricsB,
labelMapValuesB?.[0],
labelMapB?.[seriesName]?.[0],
!!contributionMode,
);
@@ -834,15 +809,15 @@ export default function transformProps(
.filter(key => keys.includes(key))
.forEach(key => {
const value = forecastValues[key];
// The tooltip key is the rendered series name; resolve it through
// the display-keyed maps, whose values lead with the raw metric
// label both with and without dimensions. Fall back to the
// verbose-name inversion for series absent from the maps.
// if there are no dimensions, key is a verbose name of a metric,
// otherwise it is a comma separated string where the first part is metric name
let formatterKey;
if (primarySeries.has(key)) {
formatterKey = displayLabelMap[key]?.[0] ?? inverted[key];
formatterKey =
groupby.length === 0 ? inverted[key] : labelMap[key]?.[0];
} else {
formatterKey = displayLabelMapB[key]?.[0] ?? inverted[key];
formatterKey =
groupbyB.length === 0 ? inverted[key] : labelMapB[key]?.[0];
}
const tooltipFormatter = getFormatter(
customFormatters,
@@ -937,8 +912,8 @@ export default function transformProps(
echartOptions: mergedEchartOptions,
setDataMask,
emitCrossFilters,
labelMap: displayLabelMap,
labelMapB: displayLabelMapB,
labelMap,
labelMapB,
groupby,
groupbyB,
seriesBreakdown: rawSeriesA.length,

View File

@@ -124,7 +124,7 @@ use([
const loadLocale = async (locale: string) => {
let lang;
try {
lang = await import(`echarts/i18n/lang${locale}.js`);
lang = await import(`echarts/lib/i18n/lang${locale}`);
} catch {
// Locale not supported in ECharts
}

View File

@@ -1,223 +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 { render } from '@testing-library/react';
import {
ChartDataResponseResult,
DataRecord,
VizType,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import transformProps from '../../src/MixedTimeseries/transformProps';
import EchartsMixedTimeseries from '../../src/MixedTimeseries/EchartsMixedTimeseries';
import {
DEFAULT_FORM_DATA,
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps,
} from '../../src/MixedTimeseries/types';
import Echart from '../../src/components/Echart';
import { createEchartsTimeseriesTestChartProps } from '../helpers';
jest.mock('../../src/components/Echart', () => ({
__esModule: true,
default: jest.fn(() => null),
}));
const mockedEchart = jest.mocked(Echart);
const ts1 = 1704067200000;
const ts2 = 1704153600000;
/**
* Backend-shaped fixture: flattened column names keep the metric label, so
* label_map values lead with the metric label ahead of the dimension value.
*/
function createQueryData(): ChartDataResponseResult {
const rows = [
{ ds: ts1, 'sum__num, boy': 1, 'sum__num, girl': 2 },
{ ds: ts2, 'sum__num, boy': 3, 'sum__num, girl': 4 },
];
return {
annotation_data: null,
cache_key: null,
cache_timeout: null,
cached_dttm: null,
queried_dttm: null,
data: rows as DataRecord[],
colnames: ['ds', 'sum__num, boy', 'sum__num, girl'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
error: null,
is_cached: false,
query: '',
rowcount: rows.length,
sql_rowcount: rows.length,
stacktrace: null,
status: 'success',
from_dttm: null,
to_dttm: null,
label_map: {
ds: ['ds'],
'sum__num, boy': ['sum__num', 'boy'],
'sum__num, girl': ['sum__num', 'girl'],
},
} as ChartDataResponseResult;
}
function setup(
formDataOverrides: Partial<EchartsMixedTimeseriesFormData> = {},
) {
const queryData = createQueryData();
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
defaultFormData: DEFAULT_FORM_DATA as EchartsMixedTimeseriesFormData,
defaultVizType: 'mixed_timeseries',
defaultQueriesData: [queryData, queryData],
formData: {
colorScheme: 'bnbColors',
metrics: ['sum__num'],
metricsB: ['sum__num'],
groupby: ['gender'],
groupbyB: ['gender'],
x_axis: 'ds',
viz_type: VizType.MixedTimeseries,
...formDataOverrides,
},
queriesData: [queryData, queryData],
});
const transformed = transformProps(chartProps);
const setDataMask = jest.fn();
const onContextMenu = jest.fn();
const onFocusedSeries = jest.fn();
render(
<EchartsMixedTimeseries
{...transformed}
setDataMask={setDataMask}
onContextMenu={onContextMenu}
onFocusedSeries={onFocusedSeries}
emitCrossFilters
/>,
);
const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1];
const { eventHandlers } = lastCall[0] as any;
return { eventHandlers, setDataMask, onContextMenu, onFocusedSeries };
}
beforeEach(() => {
mockedEchart.mockClear();
});
test('EchartsMixedTimeseries click emits cross-filter with tail-anchored dimension values', () => {
const { eventHandlers, setDataMask } = setup();
eventHandlers.click({ seriesName: 'sum__num, boy', seriesIndex: 0 });
expect(setDataMask).toHaveBeenCalledTimes(1);
const dataMask = setDataMask.mock.calls[0][0];
// label_map values are ['sum__num', 'boy'] — the metric label must be
// skipped and the dimension value emitted.
expect(dataMask.extraFormData.filters).toEqual([
{ col: 'gender', op: 'IN', val: ['boy'] },
]);
expect(dataMask.filterState.selectedValues).toEqual(['sum__num, boy']);
});
test('EchartsMixedTimeseries click clears filters when the series misses the label map', () => {
const { eventHandlers, setDataMask } = setup();
eventHandlers.click({ seriesName: 'not a series', seriesIndex: 0 });
expect(setDataMask).toHaveBeenCalledTimes(1);
const dataMask = setDataMask.mock.calls[0][0];
// An unresolvable series must not emit bogus IS NULL filters (#41622).
expect(dataMask.extraFormData.filters).toEqual([]);
expect(dataMask.filterState.value).toBeNull();
});
test('EchartsMixedTimeseries context menu drills with tail-anchored dimension values', async () => {
const { eventHandlers, onContextMenu } = setup();
await eventHandlers.contextmenu({
data: [ts1, 1],
seriesName: 'sum__num, boy',
seriesIndex: 0,
name: '',
event: { stop: jest.fn(), event: { clientX: 11, clientY: 22 } },
});
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [x, y, payload] = onContextMenu.mock.calls[0];
expect(x).toBe(11);
expect(y).toBe(22);
expect(payload.drillToDetail).toEqual([
expect.objectContaining({ col: 'ds', op: '==', val: ts1 }),
expect.objectContaining({
col: 'gender',
op: '==',
val: 'boy',
formattedVal: 'boy',
}),
]);
expect(payload.drillBy.filters).toEqual([
expect.objectContaining({ col: 'gender', op: '==', val: 'boy' }),
]);
expect(payload.drillBy.groupbyFieldName).toBe('groupby');
expect(payload.crossFilter.dataMask.extraFormData.filters).toEqual([
{ col: 'gender', op: 'IN', val: ['boy'] },
]);
});
test('EchartsMixedTimeseries context menu emits the category x-axis filter', async () => {
const { eventHandlers, onContextMenu } = setup({
xAxisForceCategorical: true,
});
await eventHandlers.contextmenu({
data: ['boy-cat', 1],
seriesName: 'sum__num, girl',
seriesIndex: 1,
name: 'boy-cat',
event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
});
const payload = onContextMenu.mock.calls[0][2];
expect(payload.drillToDetail).toEqual([
expect.objectContaining({
col: 'ds',
op: '==',
val: 'boy-cat',
formattedVal: 'boy-cat',
}),
expect.objectContaining({ col: 'gender', op: '==', val: 'girl' }),
]);
});
test('EchartsMixedTimeseries hover focuses and unfocuses the series', () => {
const { eventHandlers, onFocusedSeries } = setup();
eventHandlers.mouseover({ seriesName: 'sum__num, boy' });
expect(onFocusedSeries).toHaveBeenLastCalledWith('sum__num, boy');
eventHandlers.mouseout();
expect(onFocusedSeries).toHaveBeenLastCalledWith(null);
});

View File

@@ -777,198 +777,6 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
expect(xAxis.type).toBe(AxisType.Category);
});
// labelMap/labelMapB must be keyed by the rendered series names or the
// cross-filter/drill lookups in EchartsMixedTimeseries miss (#41622);
// see the re-key comment in transformProps.ts.
test('cross-filter label maps are keyed by the rendered series names', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, showQueryIdentifiers: false },
queriesData,
});
const transformed = transformProps(chartProps);
// The backend label_map is keyed by the flattened column names
// ("boy"/"girl") while the rendered series are "sum__num, boy" etc.
expect(transformed.labelMap).toEqual({
'sum__num, boy': ['boy'],
'sum__num, girl': ['girl'],
});
expect(transformed.labelMapB).toEqual({
'sum__num, boy': ['boy'],
'sum__num, girl': ['girl'],
});
});
test('cross-filter label maps resolve every rendered series name', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, showQueryIdentifiers: true },
queriesData,
});
const transformed = transformProps(chartProps);
const names = (transformed.echartOptions.series as SeriesOption[]).map(
series => String(series.name),
);
expect(names).toHaveLength(4);
names
.slice(0, transformed.seriesBreakdown)
.forEach(name => expect(transformed.labelMap[name]).toBeDefined());
names
.slice(transformed.seriesBreakdown)
.forEach(name => expect(transformed.labelMapB[name]).toBeDefined());
});
test('cross-filter label maps resolve verbose series names to raw label_map values', () => {
const verboseRows = [
{ ds: 599616000000, sum__num: 1 },
{ ds: 599916000000, sum__num: 3 },
];
const verboseQueryData = createTestQueryData(verboseRows, {
label_map: { ds: ['ds'], sum__num: ['sum__num'] },
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [verboseQueryData, verboseQueryData],
formData: { ...formData, groupby: [], groupbyB: [] },
queriesData: [verboseQueryData, verboseQueryData],
datasource: {
verboseMap: { sum__num: 'Total Births' },
},
});
const transformed = transformProps(chartProps);
// rebaseForecastDatum renames data columns to their verbose names, so the
// rendered series is "Total Births" while label_map stays keyed by
// "sum__num" — the display-keyed map bridges the two.
expect(transformed.labelMap['Total Births']).toEqual(['sum__num']);
expect(transformed.labelMapB['Total Births']).toEqual(['sum__num']);
});
test('tooltip resolves per-metric formats through the display-keyed label map', () => {
// Multi-metric so getCustomFormatter cannot short-circuit on a single
// saved metric: the formatter key must come from resolving the rendered
// series name through the display-keyed map.
const rows = [{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 }];
const queryData = createTestQueryData(rows, {
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
label_map: {
ds: ['ds'],
'sum__num, boy': ['sum__num', 'boy'],
'avg__num, boy': ['avg__num', 'boy'],
},
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryData, queryData],
formData: {
...formData,
metrics: ['sum__num', 'avg__num'],
x_axis: 'ds',
yAxisFormat: undefined,
},
queriesData: [queryData, queryData],
datasource: {
columnFormats: { sum__num: '.2%' },
},
});
const transformed = transformProps(chartProps);
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
params: unknown,
) => string;
const html = formatter({
value: [599616000000, 0.5],
seriesId: 'sum__num, boy',
marker: '',
color: '#333',
});
expect(html).toContain('50.00%');
});
test('tooltip resolves per-metric formats for secondary-query series', () => {
const rowsA = [
{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 },
];
const queryDataA = createTestQueryData(rowsA, {
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
label_map: {
ds: ['ds'],
'sum__num, boy': ['sum__num', 'boy'],
'avg__num, boy': ['avg__num', 'boy'],
},
});
const rowsB = [{ ds: 599616000000, 'count__num, boy': 2.5 }];
const queryDataB = createTestQueryData(rowsB, {
colnames: ['ds', 'count__num, boy'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: {
ds: ['ds'],
'count__num, boy': ['count__num', 'boy'],
},
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryDataA, queryDataB],
formData: {
...formData,
metrics: ['sum__num', 'avg__num'],
metricsB: ['count__num', 'max__num'],
x_axis: 'ds',
yAxisFormat: undefined,
yAxisFormatSecondary: undefined,
yAxisIndex: 0,
yAxisIndexB: 1,
},
queriesData: [queryDataA, queryDataB],
datasource: {
columnFormats: { count__num: '.1f' },
},
});
const transformed = transformProps(chartProps);
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
params: unknown,
) => string;
const html = formatter({
value: [599616000000, 2.5],
seriesId: 'count__num, boy',
marker: '',
color: '#333',
});
expect(html).toContain('2.5');
});
test('temporal x coltype wires the time formatter and Time axis', () => {
// Regression guard: the happy path for mixed-timeseries charts. Ensures
// Temporal coltype still routes through the TimeFormatter so the time axis

View File

@@ -20,14 +20,13 @@
import {
ReactNode,
MouseEvent,
SyntheticEvent,
useState,
useCallback,
useRef,
useMemo,
useEffect,
} from 'react';
import { safeHtmlSpan, handleKeyboardActivation } from '@superset-ui/core';
import { safeHtmlSpan } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { supersetTheme } from '@apache-superset/core/theme';
import PropTypes from 'prop-types';
@@ -155,10 +154,7 @@ function displayCell(value: unknown, allowRenderHtml?: boolean): ReactNode {
function displayHeaderCell(
needToggle: boolean,
ArrowIcon: ReactNode,
// `SyntheticEvent` (rather than `MouseEvent`) so this callback can also be
// used as the keyboard-activation handler via `handleKeyboardActivation`,
// which invokes it with a `KeyboardEvent`.
onArrowClick: ((e: SyntheticEvent) => void) | null,
onArrowClick: ((e: MouseEvent<HTMLSpanElement>) => void) | null,
value: unknown,
namesMapping: Record<string, string>,
allowRenderHtml?: boolean,
@@ -176,9 +172,6 @@ function displayHeaderCell(
tabIndex={0}
className="toggle"
onClick={onArrowClick || undefined}
onKeyDown={
onArrowClick ? handleKeyboardActivation(onArrowClick) : undefined
}
>
{ArrowIcon}
</span>
@@ -469,7 +462,7 @@ export function TableRenderer(props: TableRendererProps) {
);
const toggleRowKey = useCallback(
(flatRowKey: string) => (e: SyntheticEvent) => {
(flatRowKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
e.stopPropagation();
setCollapsedRows(state => ({
...state,
@@ -480,7 +473,7 @@ export function TableRenderer(props: TableRendererProps) {
);
const toggleColKey = useCallback(
(flatColKey: string) => (e: SyntheticEvent) => {
(flatColKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
e.stopPropagation();
setCollapsedCols(state => ({
...state,
@@ -1053,7 +1046,6 @@ export function TableRenderer(props: TableRendererProps) {
onClick={e => {
e.stopPropagation();
}}
onKeyDown={e => e.stopPropagation()}
aria-label={
activeSortColumn === i
? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`

View File

@@ -119,7 +119,7 @@ jest.mock('@superset-ui/core/components/Icons/AsyncIcon', () => ({
const label =
ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || '';
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
ref={ref}
role={role || (onClick ? 'button' : 'img')}

View File

@@ -1998,56 +1998,4 @@ describe('async actions', () => {
});
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('toggleLeftBar', () => {
const activeId = 'active-qe';
const makeState = (hideLeftBar: boolean, unsavedOverride?: boolean) => ({
sqlLab: {
tabHistory: [activeId],
queryEditors: [{ id: activeId, hideLeftBar }],
unsavedQueryEditor:
unsavedOverride !== undefined
? { id: activeId, hideLeftBar: unsavedOverride }
: {},
},
});
test('dispatches QUERY_EDITOR_TOGGLE_LEFT_BAR when state differs', () => {
const store = mockStore(makeState(false));
store.dispatch(actions.toggleLeftBar(true));
expect(store.getActions()).toEqual([
{
type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR,
queryEditorId: activeId,
hideLeftBar: true,
},
]);
});
test('does not dispatch when state is already the same', () => {
const store = mockStore(makeState(true));
store.dispatch(actions.toggleLeftBar(true));
expect(store.getActions()).toHaveLength(0);
});
test('uses unsavedQueryEditor state when available', () => {
// qe in queryEditors says false, but unsaved override says true
const store = mockStore(makeState(false, true));
store.dispatch(actions.toggleLeftBar(true));
expect(store.getActions()).toHaveLength(0);
});
test('does not dispatch when there is no active query editor', () => {
const store = mockStore({
sqlLab: {
tabHistory: [],
queryEditors: [],
unsavedQueryEditor: {},
},
});
store.dispatch(actions.toggleLeftBar(true));
expect(store.getActions()).toHaveLength(0);
});
});
});

View File

@@ -956,24 +956,12 @@ export function setActiveSouthPaneTab(tabId: string): SqlLabAction {
return { type: SET_ACTIVE_SOUTHPANE_TAB, tabId };
}
export function toggleLeftBar(shouldHide: boolean): SqlLabThunkAction {
return (dispatch: AppDispatch, getState: GetState) => {
const { sqlLab } = getState();
const id = sqlLab.tabHistory.slice(-1)[0];
if (!id) return;
const qe = sqlLab.queryEditors.find(e => e.id === id);
const merged =
qe && sqlLab.unsavedQueryEditor?.id === id
? { ...qe, ...sqlLab.unsavedQueryEditor }
: qe;
if (!merged) return;
const isCurrentlyHidden = Boolean(merged.hideLeftBar);
if (shouldHide === isCurrentlyHidden) return;
dispatch({
type: QUERY_EDITOR_TOGGLE_LEFT_BAR,
queryEditorId: id,
hideLeftBar: shouldHide,
});
export function toggleLeftBar(queryEditor: QueryEditor): SqlLabAction {
const hideLeftBar = !queryEditor.hideLeftBar;
return {
type: QUERY_EDITOR_TOGGLE_LEFT_BAR,
queryEditor,
hideLeftBar,
};
}

View File

@@ -21,7 +21,6 @@ import { render, userEvent, waitFor } from 'spec/helpers/testing-library';
import { initialState } from 'src/SqlLab/fixtures';
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
import { ViewLocations } from 'src/SqlLab/contributions';
import * as sqlLabActions from 'src/SqlLab/actions/sqlLab';
import {
registerTestView,
cleanupExtensions,
@@ -109,46 +108,6 @@ test('right sidebar is hidden when no extensions registered', () => {
expect(queryByText('Right Sidebar Content')).not.toBeInTheDocument();
});
test('dispatches toggleLeftBar(true) when sidebar is resized to zero', async () => {
const toggleLeftBarSpy = jest
.spyOn(sqlLabActions, 'toggleLeftBar')
.mockReturnValue({
type: sqlLabActions.QUERY_EDITOR_TOGGLE_LEFT_BAR,
} as any);
const { getByRole } = render(<AppLayout {...defaultProps} />, {
useRedux: true,
initialState,
});
await userEvent.click(getByRole('button', { name: 'Resize to zero' }));
await waitFor(() => expect(toggleLeftBarSpy).toHaveBeenCalledWith(true));
toggleLeftBarSpy.mockRestore();
});
test('dispatches toggleLeftBar(false) when sidebar is resized to non-zero', async () => {
const collapsedState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: initialState.sqlLab.tabHistory[0],
hideLeftBar: true,
},
},
};
const toggleLeftBarSpy = jest
.spyOn(sqlLabActions, 'toggleLeftBar')
.mockReturnValue({
type: sqlLabActions.QUERY_EDITOR_TOGGLE_LEFT_BAR,
} as any);
const { getByRole } = render(<AppLayout {...defaultProps} />, {
useRedux: true,
initialState: collapsedState,
});
await userEvent.click(getByRole('button', { name: 'Resize' }));
await waitFor(() => expect(toggleLeftBarSpy).toHaveBeenCalledWith(false));
toggleLeftBarSpy.mockRestore();
});
test('renders right sidebar when view is contributed at rightSidebar location', () => {
registerTestView(
ViewLocations.sqllab.rightSidebar,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { noop } from 'lodash-es';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { css, styled } from '@apache-superset/core/theme';
@@ -32,7 +32,6 @@ import {
} from 'src/SqlLab/constants';
import { ViewLocations } from 'src/SqlLab/contributions';
import ViewListExtension from 'src/components/ViewListExtension';
import { toggleLeftBar } from 'src/SqlLab/actions/sqlLab';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
import StatusBar from '../StatusBar';
@@ -67,7 +66,6 @@ const ContentWrapper = styled.div`
`;
const AppLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const dispatch = useDispatch();
const queryEditorId = useSelector<SqlLabRootState, string>(
({ sqlLab: { tabHistory } }) => tabHistory.slice(-1)[0],
);
@@ -93,7 +91,6 @@ const AppLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const onSidebarChange = (sizes: number[]) => {
const [updatedWidth, _, possibleRightWidth] = sizes;
setLeftWidth(updatedWidth);
dispatch(toggleLeftBar(updatedWidth === 0));
if (typeof possibleRightWidth === 'number') {
setRightWidth(possibleRightWidth);

View File

@@ -121,7 +121,6 @@ import KeyboardShortcutButton, {
KeyboardShortcut,
} from '../KeyboardShortcutButton';
import SqlEditorTopBar from '../SqlEditorTopBar';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
const bootstrapData = getBootstrapData();
const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
@@ -241,36 +240,34 @@ const SqlEditor: FC<Props> = ({
const theme = useTheme();
const dispatch = useAppDispatch();
const {
database,
latestQuery,
hideLeftBar,
currentQueryEditorId,
hasSqlStatement,
} = useSelector<
SqlLabRootState,
{
database?: DatabaseObject;
latestQuery?: QueryResponse;
hideLeftBar?: boolean;
currentQueryEditorId: QueryEditor['id'];
hasSqlStatement: boolean;
}
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory } }) => {
let { dbId, latestQueryId, hideLeftBar } = queryEditor;
if (unsavedQueryEditor?.id === queryEditor.id) {
dbId = unsavedQueryEditor.dbId || dbId;
latestQueryId = unsavedQueryEditor.latestQueryId || latestQueryId;
hideLeftBar = unsavedQueryEditor.hideLeftBar === true;
}
return {
hasSqlStatement: Boolean(queryEditor.sql?.trim().length > 0),
database: databases[dbId || ''],
latestQuery: queries[latestQueryId || ''],
hideLeftBar,
currentQueryEditorId: tabHistory.slice(-1)[0],
};
}, shallowEqual);
const { database, latestQuery, currentQueryEditorId, hasSqlStatement } =
useSelector<
SqlLabRootState,
{
database?: DatabaseObject;
latestQuery?: QueryResponse;
hideLeftBar?: boolean;
currentQueryEditorId: QueryEditor['id'];
hasSqlStatement: boolean;
}
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory } }) => {
let { dbId, latestQueryId, hideLeftBar } = queryEditor;
if (unsavedQueryEditor?.id === queryEditor.id) {
dbId = unsavedQueryEditor.dbId || dbId;
latestQueryId = unsavedQueryEditor.latestQueryId || latestQueryId;
hideLeftBar =
typeof unsavedQueryEditor.hideLeftBar === 'boolean'
? unsavedQueryEditor.hideLeftBar
: hideLeftBar;
}
return {
hasSqlStatement: Boolean(queryEditor.sql?.trim().length > 0),
database: databases[dbId || ''],
latestQuery: queries[latestQueryId || ''],
hideLeftBar,
currentQueryEditorId: tabHistory.slice(-1)[0],
};
}, shallowEqual);
const logAction = useLogAction({ queryEditorId: queryEditor.id });
const isActive = currentQueryEditorId === queryEditor.id;
@@ -978,11 +975,6 @@ const SqlEditor: FC<Props> = ({
queryEditorId={queryEditor.id}
defaultPrimaryActions={renderEditorPrimaryAction()}
defaultSecondaryActions={getSecondaryMenuItems()}
extra={
hideLeftBar && (
<SqlEditorLeftBar queryEditorId={queryEditor.id} collapsed />
)
}
/>
)}
{queryEditor.isDataset && renderDatasetWarning()}

View File

@@ -39,7 +39,6 @@ import TableExploreTree from '../TableExploreTree';
export interface SqlEditorLeftBarProps {
queryEditorId: string;
collapsed?: boolean;
}
const LeftBarStyles = styled.div`
@@ -64,10 +63,7 @@ const StyledDivider = styled.div`
margin: 0 -${({ theme }) => theme.sizeUnit * 2.5}px 0;
`;
const SqlEditorLeftBar = ({
queryEditorId,
collapsed = false,
}: SqlEditorLeftBarProps) => {
const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
const activeQEId = queryEditorId || EMPTY_STATE_QE_ID;
const dbSelectorProps = useDatabaseSelector(activeQEId);
const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } =
@@ -184,38 +180,29 @@ const SqlEditorLeftBar = ({
</Flex>
);
const dbSelectorTrigger = (
<Popover
content={popoverContent}
open={selectorModalOpen}
onOpenChange={open => !open && closeSelectorModal()}
placement="bottomLeft"
trigger="click"
>
{/* Wrap in a span so the Popover can attach a ref without relying
on findDOMNode (deprecated in React 18+). */}
<span>
<DatabaseSelector
key={`db-selector-${db ? db.id : 'no-db'}:${catalog ?? 'no-catalog'}:${
schema ?? 'no-schema'
}`}
{...dbSelectorProps}
emptyState={<EmptyState />}
sqlLabMode
compactMode={collapsed}
onOpenModal={openSelectorModal}
/>
</span>
</Popover>
);
if (collapsed) {
return dbSelectorTrigger;
}
return (
<LeftBarStyles data-test="sql-editor-left-bar">
{dbSelectorTrigger}
<Popover
content={popoverContent}
open={selectorModalOpen}
onOpenChange={open => !open && closeSelectorModal()}
placement="bottomLeft"
trigger="click"
>
{/* Wrap in a span so the Popover can attach a ref without relying
on findDOMNode (deprecated in React 18+). */}
<span>
<DatabaseSelector
key={`db-selector-${db ? db.id : 'no-db'}:${catalog ?? 'no-catalog'}:${
schema ?? 'no-schema'
}`}
{...dbSelectorProps}
emptyState={<EmptyState />}
sqlLabMode
onOpenModal={openSelectorModal}
/>
</span>
</Popover>
<StyledDivider />
<TableExploreTree queryEditorId={activeQEId} />
{shouldShowReset && (

View File

@@ -41,6 +41,7 @@ import {
removeAllOtherQueryEditors,
queryEditorSetTitle,
cloneQueryToNewTab,
toggleLeftBar,
} from 'src/SqlLab/actions/sqlLab';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import { Icons, type IconType } from '@superset-ui/core/components/Icons';
@@ -104,6 +105,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
removeAllOtherQueryEditors,
queryEditorSetTitle,
cloneQueryToNewTab,
toggleLeftBar,
},
dispatch,
),

View File

@@ -31,13 +31,11 @@ export interface SqlEditorTopBarProps {
queryEditorId: string;
defaultPrimaryActions: React.ReactNode;
defaultSecondaryActions: MenuItemType[];
extra?: React.ReactNode;
}
const SqlEditorTopBar = ({
defaultPrimaryActions,
defaultSecondaryActions,
extra,
}: SqlEditorTopBarProps) => (
<StyledFlex justify="space-between" gap="small" id="js-sql-toolbar">
<Flex gap="small" align="center">
@@ -49,7 +47,6 @@ const SqlEditorTopBar = ({
/>
</Flex>
</Flex>
{extra}
</StyledFlex>
);

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import type { NodeRendererProps } from 'react-arborist';
@@ -187,7 +186,6 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<span
className="col-copy-action"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
<ActionButton
label={`copy-col-${data.name}`}
@@ -217,13 +215,6 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
node.toggle();
}
}}
onKeyDown={handleKeyboardActivation(() => {
if (node.isLeaf) {
node.select();
} else {
node.toggle();
}
})}
>
<span
className="tree-node-icon"
@@ -248,7 +239,6 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<div
className="side-action-container"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{pinnedSchemas.has(schema) && (
<div className="action-static">
@@ -316,7 +306,6 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<div
className="side-action-container"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{isPinned && (
<div className="action-static">

View File

@@ -293,45 +293,6 @@ describe('sqlLabReducer', () => {
newQueryEditor.tabViewId,
);
});
test('should toggle hideLeftBar via queryEditorId for the active editor', () => {
const action = {
type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR,
queryEditorId: qe!.id,
hideLeftBar: true,
};
newState = sqlLabReducer(newState, action as SqlLabAction);
expect(newState.unsavedQueryEditor.hideLeftBar).toBe(true);
expect(newState.unsavedQueryEditor.id).toBe(qe!.id);
});
test('should toggle hideLeftBar back to false via queryEditorId', () => {
// first set to true
newState = sqlLabReducer(newState, {
type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR,
queryEditorId: qe!.id,
hideLeftBar: true,
} as SqlLabAction);
// then back to false
newState = sqlLabReducer(newState, {
type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR,
queryEditorId: qe!.id,
hideLeftBar: false,
} as SqlLabAction);
expect(newState.unsavedQueryEditor.hideLeftBar).toBe(false);
});
test('should toggle hideLeftBar in queryEditors array for non-active editor', () => {
const nonActiveId = defaultQueryEditor.id;
const action = {
type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR,
queryEditorId: nonActiveId,
hideLeftBar: true,
};
newState = sqlLabReducer(newState, action as SqlLabAction);
const updated = newState.queryEditors.find(e => e.id === nonActiveId);
expect(updated?.hideLeftBar).toBe(true);
});
test('should clear the destroyed query editors', () => {
const expectedQEId = '1233289';
const action = {

View File

@@ -715,7 +715,7 @@ export default function sqlLabReducer(
{
hideLeftBar: action.hideLeftBar,
},
action.queryEditorId!,
action.queryEditor!.id!,
),
};
},

View File

@@ -30,7 +30,6 @@ import {
type FilterState,
type JsonObject,
type AgGridChartState,
handleKeyboardActivation,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import type { ChartState, Datasource, ChartStatus } from 'src/explore/types';
@@ -466,12 +465,7 @@ function Chart({
{t(
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
)}{' '}
<span
role="button"
tabIndex={0}
onClick={onQuery}
onKeyDown={handleKeyboardActivation(() => onQuery?.())}
>
<span role="button" tabIndex={0} onClick={onQuery}>
{t('click here')}
</span>
.

View File

@@ -17,14 +17,7 @@
* under the License.
*/
import {
SyntheticEvent,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import {
BinaryQueryObjectFilterClause,
@@ -35,7 +28,6 @@ import {
isDefined,
ContextMenuFilters,
AdhocFilter,
handleKeyboardActivation,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { css, useTheme } from '@apache-superset/core/theme';
@@ -562,12 +554,6 @@ export default function DrillByModal({
items={breadcrumbItems}
itemRender={(route, _, routes, paths) => {
const isLastElement = routes.indexOf(route) === routes.length - 1;
// `route.onClick` is typed by antd as a `MouseEventHandler`, but
// the underlying handler ignores its argument, so it's safe to
// broaden it to an optional `SyntheticEvent` callback here to
// reuse it as the keyboard-activation handler below.
const onRouteClick = route.onClick as
((event?: SyntheticEvent) => void) | undefined;
return isLastElement ? (
<span data-test="drill-by-breadcrumb-item">
{route.title}
@@ -578,8 +564,7 @@ export default function DrillByModal({
data-test="drill-by-breadcrumb-item"
role="button"
tabIndex={0}
onClick={onRouteClick}
onKeyDown={handleKeyboardActivation(() => onRouteClick?.())}
onClick={route.onClick}
css={css`
cursor: pointer;
`}

View File

@@ -217,7 +217,6 @@ export const DrillBySubmenu = ({
role="menu"
tabIndex={0}
data-test="drill-by-submenu"
onKeyDown={e => e.stopPropagation()}
css={css`
width: 220px;
max-width: 220px;

View File

@@ -186,7 +186,6 @@ export function DatabaseSelector({
onSchemaChange,
schema,
readOnly = false,
compactMode = false,
sqlLabMode = false,
onOpenModal,
}: DatabaseSelectorProps) {
@@ -383,7 +382,6 @@ export function DatabaseSelector({
});
const catalogOptions = catalogData || EMPTY_CATALOG_OPTIONS;
const sqlLabCompactMode = sqlLabMode && compactMode;
function changeDatabase(
value: { label: string; value: number },
@@ -606,8 +604,8 @@ export function DatabaseSelector({
>
{renderDatabaseSelect()}
{renderError()}
{!sqlLabCompactMode && showCatalogSelector && renderCatalogSelect()}
{!sqlLabCompactMode && showSchemaSelector && renderSchemaSelect()}
{showCatalogSelector && renderCatalogSelect()}
{showSchemaSelector && renderSchemaSelect()}
</DatabaseSelectorWrapper>
);
}

View File

@@ -50,6 +50,5 @@ export interface DatabaseSelectorProps {
schema?: string;
readOnly?: boolean;
sqlLabMode?: boolean;
compactMode?: boolean;
onOpenModal?: () => void;
}

View File

@@ -36,7 +36,6 @@ import {
SupersetClient,
getClientErrorObject,
getExtensionsRegistry,
handleKeyboardActivation,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { t } from '@apache-superset/core/translation';
@@ -1761,7 +1760,6 @@ function DatasourceEditor({
role="button"
tabIndex={0}
onClick={onChangeEditMode}
onKeyDown={handleKeyboardActivation(onChangeEditMode)}
>
{isEditMode ? (
<Icons.UnlockOutlined

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
@@ -104,7 +103,6 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
role="button"
tabIndex={0}
onClick={toggleDescription}
onKeyDown={handleKeyboardActivation(toggleDescription)}
style={{ textDecoration: 'underline', cursor: 'pointer' }}
>
{isDescriptionVisible ? t('See less') : t('See more')}
@@ -129,12 +127,7 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
return (
<>
<Tooltip title={`${errorType}: ${message}`}>
<span
role="button"
onClick={() => setShowModal(true)}
onKeyDown={handleKeyboardActivation(() => setShowModal(true))}
tabIndex={0}
>
<span role="button" onClick={() => setShowModal(true)} tabIndex={0}>
{renderTrigger()}
</span>
</Tooltip>

View File

@@ -16,13 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
within,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -365,24 +359,4 @@ describe('ListView', () => {
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalled();
});
test('switches view mode via keyboard activation of the toggle buttons', () => {
const { container } = factory({
renderCard: jest.fn(),
data: [],
count: 0,
initialSort: [{ id: 'something' }],
});
const [cardToggle, tableToggle] =
container.querySelectorAll<HTMLElement>('.toggle-button');
expect(screen.getByTestId('empty-state')).toHaveClass('card');
fireEvent.keyDown(tableToggle, { key: 'Enter' });
expect(screen.getByTestId('empty-state')).toHaveClass('table');
fireEvent.keyDown(cardToggle, { key: ' ' });
expect(screen.getByTestId('empty-state')).toHaveClass('card');
});
});

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
import { styled } from '@apache-superset/core/theme';
@@ -256,7 +255,6 @@ const ViewModeToggle = ({
e.currentTarget.blur();
setMode('card');
}}
onKeyDown={handleKeyboardActivation(() => setMode('card'))}
className={cx('toggle-button', { active: mode === 'card' })}
>
<Icons.AppstoreOutlined iconSize="xl" />
@@ -271,7 +269,6 @@ const ViewModeToggle = ({
e.currentTarget.blur();
setMode('table');
}}
onKeyDown={handleKeyboardActivation(() => setMode('table'))}
className={cx('toggle-button', { active: mode === 'table' })}
>
<Icons.UnorderedListOutlined iconSize="xl" />
@@ -512,9 +509,6 @@ export function ListView<T extends object = any>({
tabIndex={0}
className="deselect-all"
onClick={() => toggleAllRowsSelected(false)}
onKeyDown={handleKeyboardActivation(() =>
toggleAllRowsSelected(false),
)}
>
{t('Deselect all')}
</span>
@@ -550,9 +544,6 @@ export function ListView<T extends object = any>({
tabIndex={0}
className="tag-btn"
onClick={() => setShowBulkTagModal(true)}
onKeyDown={handleKeyboardActivation(() =>
setShowBulkTagModal(true),
)}
>
{t('Add Tag')}
</span>

View File

@@ -34,7 +34,7 @@ export default function useStoredSidebarWidth(
widthsMapRef.current =
widthsMapRef.current ??
getItem(LocalStorageKeys.CommonResizableSidebarWidths, {});
if (typeof widthsMapRef.current[id] === 'number') {
if (widthsMapRef.current[id]) {
setSidebarWidth(widthsMapRef.current[id]);
}
}, [id]);

View File

@@ -137,7 +137,6 @@ export function saveChartCustomization(
dispatch({
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: mergedResult,
deletedIds,
});
dispatch(

View File

@@ -50,7 +50,6 @@ export interface SetNativeFiltersConfigComplete {
filterChanges: Array<
Filter | Divider | ChartCustomization | ChartCustomizationDivider
>;
deletedIds?: string[];
}
export const SET_NATIVE_FILTERS_CONFIG_FAIL = 'SET_NATIVE_FILTERS_CONFIG_FAIL';
@@ -93,7 +92,6 @@ export const setFilterConfiguration =
dispatch({
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: response.result,
deletedIds: filterChanges.deleted,
});
dispatch(nativeFiltersConfigChanged(response.result));
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));

View File

@@ -26,17 +26,9 @@ import {
import { FeatureFlag, VizType } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
jest.mock('src/utils/cachedSupersetGet');
jest.mock('src/utils/downloadAsImage', () =>
jest.fn(() => jest.fn().mockResolvedValue(undefined)),
);
jest.mock('src/utils/downloadAsPdf', () =>
jest.fn(() => jest.fn().mockResolvedValue(undefined)),
);
const mockCachedSupersetGet = cachedSupersetGet as jest.MockedFunction<
typeof cachedSupersetGet
@@ -135,12 +127,6 @@ const openMenu = () => {
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
};
const mockDownloadAsImage = downloadAsImage as jest.MockedFunction<
typeof downloadAsImage
>;
const mockDownloadAsPdf = downloadAsPdf as jest.MockedFunction<
typeof downloadAsPdf
>;
const mockFullscreenElement = (getElement: () => Element | null) => {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
@@ -150,8 +136,6 @@ const mockFullscreenElement = (getElement: () => Element | null) => {
beforeEach(() => {
mockCachedSupersetGet.mockClear();
mockDownloadAsImage.mockClear();
mockDownloadAsPdf.mockClear();
mockCachedSupersetGet.mockResolvedValue({
response: {} as Response,
json: {
@@ -691,109 +675,6 @@ test('Should pass formData to Share menu for embed code feature', () => {
expect(screen.getByText('Share')).toBeInTheDocument();
});
test('Download submenu shows standardized export screenshot and PDF labels', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
expect(
await screen.findByText('Export screenshot (jpeg)'),
).toBeInTheDocument();
expect(screen.getByText('Export screenshot (png)')).toBeInTheDocument();
expect(screen.getByText('Export as PDF')).toBeInTheDocument();
});
test('Clicking "Export screenshot (jpeg)" calls downloadAsImage and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export screenshot (jpeg)'));
expect(downloadAsImage).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
expect.anything(),
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ chartId: SLICE_ID }),
);
});
test('Export screenshot (png) submenu shows Transparent and Solid options', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.hover(await screen.findByText('Export screenshot (png)'));
expect(await screen.findByText('Transparent background')).toBeInTheDocument();
expect(screen.getByText('Solid background')).toBeInTheDocument();
});
test('Clicking "Transparent background" calls downloadAsImage with transparent option and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.hover(await screen.findByText('Export screenshot (png)'));
userEvent.click(await screen.findByText('Transparent background'));
expect(downloadAsImage).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
expect.anything(),
{ format: 'png', backgroundType: 'transparent' },
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
chartId: SLICE_ID,
backgroundType: 'transparent',
}),
);
});
test('Clicking "Solid background" calls downloadAsImage with solid option and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.hover(await screen.findByText('Export screenshot (png)'));
userEvent.click(await screen.findByText('Solid background'));
expect(downloadAsImage).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
expect.anything(),
{ format: 'png', backgroundType: 'solid' },
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
chartId: SLICE_ID,
backgroundType: 'solid',
}),
);
});
test('Clicking "Export as PDF" calls downloadAsPdf and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export as PDF'));
expect(downloadAsPdf).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ chartId: SLICE_ID }),
);
});
test('Should show single fetched query tooltip with timestamp', async () => {
const updatedDttm = Date.parse('2024-01-28T10:00:00.000Z');
const props = createProps();

View File

@@ -50,17 +50,12 @@ import {
} from '@superset-ui/core/components';
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
import { Icons } from '@superset-ui/core/components/Icons';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenuItems';
import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG,
LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF,
} from 'src/logger/LogUtils';
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import { openInNewTab } from 'src/utils/navigationUtils';
@@ -307,90 +302,29 @@ const SliceHeaderControls = (
props.exportXLSX?.(props.slice.slice_id);
break;
case MenuKeys.DownloadAsImage: {
// Hide the dropdown menu so it is not captured in the screenshot
// menu closes with a delay, we need to hide it manually,
// so that we don't capture it on the screenshot
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
) as HTMLElement | null;
) as HTMLElement;
if (menu) {
menu.style.visibility = 'hidden';
}
Promise.resolve(
downloadAsImage(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
theme,
)(domEvent),
).finally(() => {
downloadAsImage(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
theme,
)(domEvent).then(() => {
if (menu) {
menu.style.visibility = 'visible';
}
});
// eslint-disable-next-line no-unused-expressions
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: props.slice.slice_id,
});
break;
}
case MenuKeys.DownloadAsPngTransparent:
case MenuKeys.DownloadAsPngSolid: {
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
) as HTMLElement | null;
if (menu) {
menu.style.visibility = 'hidden';
}
const backgroundType =
key === MenuKeys.DownloadAsPngTransparent ? 'transparent' : 'solid';
Promise.resolve(
downloadAsImage(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
theme,
{ format: 'png', backgroundType },
)(domEvent),
).finally(() => {
if (menu) {
menu.style.visibility = 'visible';
}
});
// eslint-disable-next-line no-unused-expressions
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG, {
chartId: props.slice.slice_id,
backgroundType,
});
break;
}
case MenuKeys.DownloadAsPdf: {
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
) as HTMLElement | null;
if (menu) {
menu.style.visibility = 'hidden';
}
Promise.resolve(
downloadAsPdf(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
)(domEvent),
).finally(() => {
if (menu) {
menu.style.visibility = 'visible';
}
});
// eslint-disable-next-line no-unused-expressions
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF, {
chartId: props.slice.slice_id,
});
break;
}
case MenuKeys.ExportPivotXlsx: {
const sliceSelector = `#chart-id-${props.slice.slice_id}`;
props.exportPivotExcel?.(
@@ -680,30 +614,9 @@ const SliceHeaderControls = (
: []),
{
key: MenuKeys.DownloadAsImage,
label: t('Export screenshot (jpeg)'),
label: t('Download as image'),
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
},
{
type: 'submenu',
key: 'download_as_png_submenu',
label: t('Export screenshot (png)'),
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
children: [
{
key: MenuKeys.DownloadAsPngTransparent,
label: t('Transparent background'),
},
{
key: MenuKeys.DownloadAsPngSolid,
label: t('Solid background'),
},
],
},
{
key: MenuKeys.DownloadAsPdf,
label: t('Export as PDF'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
],
});
}

View File

@@ -106,7 +106,6 @@ export default function URLShortLinkButton({
onClick={e => {
e.stopPropagation();
}}
onKeyDown={e => e.stopPropagation()}
>
<CopyToClipboard
text={shortUrl}

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import {
Fragment,
useCallback,
@@ -352,9 +351,6 @@ const Tab = (props: TabProps): ReactElement => {
role="button"
tabIndex={0}
onClick={() => dispatch(setEditMode(true))}
onKeyDown={handleKeyboardActivation(() =>
dispatch(setEditMode(true)),
)}
>
{t('edit mode')}
</span>

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { ReactNode, FC, memo } from 'react';
import { getFilterBarTestId } from '../utils';
@@ -33,7 +32,6 @@ export const FilterConfigurationLink: FC<FCBProps> = ({
<div
{...getFilterBarTestId('create-filter')}
onClick={onClick}
onKeyDown={onClick ? handleKeyboardActivation(onClick) : undefined}
role="button"
tabIndex={0}
>

View File

@@ -190,115 +190,6 @@ test('does not render loading spinner when filter has no data source', () => {
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
const defaultFirstItemParentFilter = createMockFilter({
id: 'NATIVE_FILTER-PARENT',
controlValues: { defaultToFirstItem: true },
});
const guardChildFilter = createMockFilter({
id: 'NATIVE_FILTER-CHILD',
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
});
const stateWithDefaultFirstItemParent = {
nativeFilters: {
filters: {
'NATIVE_FILTER-CHILD': guardChildFilter,
'NATIVE_FILTER-PARENT': defaultFirstItemParentFilter,
},
filterSets: {},
},
};
test('guard: does not fetch while a defaultToFirstItem parent has not yet auto-selected', () => {
// Reproduces sc-108451: B should not fetch from unfiltered data before A selects.
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
mockUseFilterDependencies.mockReturnValue({});
renderFilterValue(
{
filter: guardChildFilter,
// Parent entry exists but filterState.value is undefined (no selection yet).
dataMaskSelected: {
'NATIVE_FILTER-PARENT': { filterState: {}, extraFormData: {} },
},
},
stateWithDefaultFirstItemParent,
);
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});
test('guard: fetches once a defaultToFirstItem parent has set its first value', async () => {
mockGetChartDataRequest.mockResolvedValue({
response: { status: 200 },
json: { result: [{ data: [{ model: 'Corolla' }] }] },
});
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
mockUseFilterDependencies.mockReturnValue({
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
});
renderFilterValue(
{
filter: guardChildFilter,
dataMaskSelected: {
'NATIVE_FILTER-PARENT': {
// Parent has auto-selected its first value → guard should pass.
filterState: { value: ['Toyota'] },
extraFormData: {
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
},
},
},
},
stateWithDefaultFirstItemParent,
);
await waitFor(() => {
expect(mockGetChartDataRequest).toHaveBeenCalled();
});
});
test('guard: does not block fetch for a parent without defaultToFirstItem', async () => {
// Non-defaultToFirstItem parents with values should pass the guard as before.
mockGetChartDataRequest.mockResolvedValue({
response: { status: 200 },
json: { result: [{ data: [] }] },
});
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
mockUseFilterDependencies.mockReturnValue({
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
});
const regularParent = createMockFilter({ id: 'NATIVE_FILTER-PARENT' });
renderFilterValue(
{
filter: guardChildFilter,
dataMaskSelected: {
'NATIVE_FILTER-PARENT': {
filterState: { value: ['Toyota'] },
extraFormData: {
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
},
},
},
},
{
nativeFilters: {
filters: {
'NATIVE_FILTER-CHILD': guardChildFilter,
'NATIVE_FILTER-PARENT': regularParent,
},
filterSets: {},
},
},
);
await waitFor(() => {
expect(mockGetChartDataRequest).toHaveBeenCalled();
});
});
test('skips data fetch when cascade parent filters have no values selected', () => {
// useFilterDependencies returns dependencies with a filter (from parent defaults),
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so

View File

@@ -44,7 +44,7 @@ import {
} from '@superset-ui/core';
import { styled, SupersetTheme } from '@apache-superset/core/theme';
import { useTheme } from '@emotion/react';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { isEqual, isEqualWith } from 'lodash-es';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ErrorAlert, ErrorMessageWithStackTrace } from 'src/components';
@@ -125,19 +125,6 @@ const FilterValue: FC<FilterValueProps> = ({
const transitiveParentIds = useTransitiveParentIds(id);
const shouldRefresh = useShouldFilterRefresh();
// Derive only the defaultToFirstItem flag per filter to avoid re-renders
// when unrelated filter config fields change.
const parentDefaultToFirstItem = useSelector(
(state: RootState) =>
Object.fromEntries(
Object.entries(state.nativeFilters?.filters ?? {}).map(([fId, f]) => [
fId,
Boolean(f.controlValues?.defaultToFirstItem),
]),
),
shallowEqual,
);
const behaviors = useMemo(
() => [
isCustomization ? Behavior.ChartCustomization : Behavior.NativeFilter,
@@ -216,21 +203,6 @@ const FilterValue: FC<FilterValueProps> = ({
// direct parents) so the counts line up with `dependencies`, which is
// itself built from the transitive chain by `useFilterDependencies`.
// Block if any parent with defaultToFirstItem hasn't auto-selected yet.
// Without this, the child fetches unfiltered options before the parent
// auto-selects, leading to a stale first-value dispatch that never
// gets corrected because subsequent re-selections are not first-initialization.
const hasDefaultFirstParentPending = transitiveParentIds.some(pId => {
const parentMask = dataMaskSelected?.[pId];
return (
parentDefaultToFirstItem[pId] &&
parentMask?.filterState?.value === undefined
);
});
if (hasDefaultFirstParentPending) {
return;
}
let selectedParentFilterValueCounts = 0;
let isTimeRangeSelected = false;
transitiveParentIds.forEach(pId => {
@@ -335,7 +307,6 @@ const FilterValue: FC<FilterValueProps> = ({
dataMaskSelected,
setHasDepsFilterValue,
transitiveParentIds,
parentDefaultToFirstItem,
]);
useEffect(() => {

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { forwardRef, useCallback, useState } from 'react';
import { t } from '@apache-superset/core/translation';
@@ -200,7 +199,6 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
e.preventDefault();
restoreFilter(id);
}}
onKeyDown={handleKeyboardActivation(() => restoreFilter(id))}
>
{t('Undo?')}
</span>

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { forwardRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
@@ -168,7 +167,6 @@ const ItemTitleContainer = forwardRef<HTMLDivElement, Props>(
e.preventDefault();
restoreItem(id);
}}
onKeyDown={handleKeyboardActivation(() => restoreItem(id))}
>
{t('Undo?')}
</span>

View File

@@ -287,14 +287,13 @@ test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes deleted filters from state', ()
},
};
// filter2 was deleted: it's absent from the payload and listed in deletedIds
// Backend response only includes filter1 and filter3 (filter2 was deleted)
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockFilter('filter1', [1, 2], ['tab1']),
createMockFilter('filter3', [5, 6], ['tab3']),
],
deletedIds: ['filter2'],
};
const result = nativeFilterReducer(initialState, action);
@@ -306,7 +305,7 @@ test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes deleted filters from state', ()
expect(Object.keys(result.filters)).toHaveLength(2);
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes all filters when all ids are deleted', () => {
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes all filters when backend returns empty array', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
@@ -317,7 +316,6 @@ test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes all filters when all ids are de
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [],
deletedIds: ['filter1', 'filter2'],
};
const result = nativeFilterReducer(initialState, action);
@@ -326,36 +324,6 @@ test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes all filters when all ids are de
expect(result.filters).toEqual({});
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE preserves untouched filters when payload omits them', () => {
// Regression for the crash where saving a chart customization wiped native
// filters: a customization-only payload (no deletedIds) must not drop the
// native filters sharing the map.
const initialState = {
filters: {
nativeFilter1: createMockFilter('nativeFilter1', [1, 2], ['tab1']),
customization1: createMockChartCustomization(
'customization1',
[3, 4],
['tab2'],
),
},
};
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockChartCustomization('customization1', [5, 6], ['tab3']),
],
};
const result = nativeFilterReducer(initialState, action);
expect(result.filters.nativeFilter1).toBeDefined();
expect(result.filters.nativeFilter1.chartsInScope).toEqual([1, 2]);
expect(result.filters.customization1.chartsInScope).toEqual([5, 6]);
expect(Object.keys(result.filters)).toHaveLength(2);
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE handles mixed Filter and ChartCustomization types', () => {
const initialState = {
filters: {
@@ -392,14 +360,13 @@ test('SET_NATIVE_FILTERS_CONFIG_COMPLETE adds new filters while removing deleted
},
};
// keep filter1, delete filter2, add filter3
// Backend response: keep filter1, delete filter2, add filter3
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockFilter('filter1', [1, 2], ['tab1']),
createMockFilter('filter3', [5, 6], ['tab3']),
],
deletedIds: ['filter2'],
};
const result = nativeFilterReducer(initialState, action);
@@ -411,7 +378,7 @@ test('SET_NATIVE_FILTERS_CONFIG_COMPLETE adds new filters while removing deleted
expect(Object.keys(result.filters)).toHaveLength(2);
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE merges payload and applies explicit deletions', () => {
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE treats backend response as source of truth', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
@@ -425,28 +392,25 @@ test('SET_NATIVE_FILTERS_CONFIG_COMPLETE merges payload and applies explicit del
},
};
// Payload updates filter2 and customization1; filter3 is explicitly deleted;
// filter1 is untouched and must survive.
// Backend only returns filter2 and customization1
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockFilter('filter2', [10, 11], ['tab5']),
createMockChartCustomization('customization1', [12, 13], ['tab6']),
],
deletedIds: ['filter3'],
};
const result = nativeFilterReducer(initialState, action);
expect(result.filters.filter1).toBeDefined();
// Only filter2 and customization1 should remain
expect(result.filters.filter1).toBeUndefined();
expect(result.filters.filter2).toBeDefined();
expect(result.filters.filter3).toBeUndefined();
expect(result.filters.customization1).toBeDefined();
expect(Object.keys(result.filters)).toHaveLength(3);
expect(Object.keys(result.filters)).toHaveLength(2);
// Untouched filter keeps its previous values
expect(result.filters.filter1.chartsInScope).toEqual([1, 2]);
// Updated values come from the payload
// Values should be from backend response
expect(result.filters.filter2.chartsInScope).toEqual([10, 11]);
expect(result.filters.filter2.tabsInScope).toEqual(['tab5']);
expect(result.filters.customization1.chartsInScope).toEqual([12, 13]);

View File

@@ -76,17 +76,12 @@ function handleFilterChangesComplete(
filters: Array<
Filter | Divider | ChartCustomization | ChartCustomizationDivider
>,
deletedIds: string[] = [],
) {
// Native filters and chart customizations share this map, but each save path
// only reports its own domain. Merge the incoming changes into the existing
// map (instead of rebuilding from scratch) so, e.g., saving a chart
// customization doesn't drop native filters and vice versa. Deletions are
// applied explicitly via deletedIds.
// Create new filters object from backend response (deleted filters won't be included)
const newFilters: Record<
string,
Filter | Divider | ChartCustomization | ChartCustomizationDivider
> = { ...state.filters };
> = {};
filters.forEach(filter => {
const existingFilter = state.filters[filter.id];
@@ -101,10 +96,6 @@ function handleFilterChangesComplete(
}
});
deletedIds.forEach(id => {
delete newFilters[id];
});
return {
...state,
filters: newFilters,
@@ -152,11 +143,7 @@ export default function nativeFilterReducer(
return getInitialState({ filterConfig: action.filterConfig, state });
case SET_NATIVE_FILTERS_CONFIG_COMPLETE:
return handleFilterChangesComplete(
state,
action.filterChanges,
action.deletedIds,
);
return handleFilterChangesComplete(state, action.filterChanges);
case SET_FOCUSED_NATIVE_FILTER:
return {

View File

@@ -367,9 +367,6 @@ export interface SliceEntitiesState {
export enum MenuKeys {
DownloadAsImage = 'download_as_image',
DownloadAsPngTransparent = 'download_as_png_transparent',
DownloadAsPngSolid = 'download_as_png_solid',
DownloadAsPdf = 'download_as_pdf',
ExploreChart = 'explore_chart',
ExportCsv = 'export_csv',
ExportPivotCsv = 'export_pivot_csv',

View File

@@ -168,10 +168,3 @@ test('getRelatedChartsForChartCustomization falls back to ID match when type is
getRelatedChartsForChartCustomization(customization, mixedSlices).sort(),
).toEqual([10, 11]);
});
test('getRelatedCharts returns empty array when the filter is undefined', () => {
// A native filter can transiently disappear from the redux map (e.g. right
// after saving a chart customization) while it is still hovered/focused.
// Guard against reading .scope on undefined so the dashboard doesn't crash.
expect(getRelatedCharts('missing', undefined, slices)).toEqual([]);
});

View File

@@ -84,13 +84,10 @@ function getRelatedChartsForCrossFilter(
export function getRelatedCharts(
filterKey: string,
filter: AppliedNativeFilterType | AppliedCrossFilterType | Filter | undefined,
filter: AppliedNativeFilterType | AppliedCrossFilterType | Filter,
slices: Record<string, Slice>,
) {
let related: number[] = [];
if (!filter) {
return related;
}
const isCrossFilter =
Object.keys(slices).includes(filterKey) && isAppliedCrossFilterType(filter);

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { FC, ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, useTheme, SupersetTheme } from '@apache-superset/core/theme';
@@ -140,7 +139,6 @@ const ControlHeader: FC<ControlHeaderProps> = ({
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={onClick ? handleKeyboardActivation(onClick) : undefined}
style={{ cursor: onClick ? 'pointer' : '' }}
>
{label}

View File

@@ -18,11 +18,7 @@
*/
import { useCallback, useEffect, useMemo, useState, MouseEvent } from 'react';
import { t } from '@apache-superset/core/translation';
import {
isFeatureEnabled,
FeatureFlag,
handleKeyboardActivation,
} from '@superset-ui/core';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import Tabs from '@superset-ui/core/components/Tabs';
@@ -191,9 +187,6 @@ export const DataTablesPane = ({
role="button"
tabIndex={0}
onClick={() => handleCollapseChange(false)}
onKeyDown={handleKeyboardActivation(() =>
handleCollapseChange(false),
)}
>
{caretIcon}
</span>
@@ -202,9 +195,6 @@ export const DataTablesPane = ({
role="button"
tabIndex={0}
onClick={() => handleCollapseChange(true)}
onKeyDown={handleKeyboardActivation(() =>
handleCollapseChange(true),
)}
>
{caretIcon}
</span>

View File

@@ -18,12 +18,7 @@
*/
import { useContext, useDeferredValue, useMemo, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import {
DatasourceType,
Metric,
QueryFormData,
handleKeyboardActivation,
} from '@superset-ui/core';
import { DatasourceType, Metric, QueryFormData } from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { css, styled, useTheme } from '@apache-superset/core/theme';
@@ -295,9 +290,6 @@ export default function DataSourcePanel({
role="button"
tabIndex={0}
onClick={() => setShowSaveDatasetModal(true)}
onKeyDown={handleKeyboardActivation(() =>
setShowSaveDatasetModal(true),
)}
className="add-dataset-alert-description"
>
{t('Create a dataset')}

View File

@@ -30,7 +30,6 @@ import {
QueryFormData,
JsonObject,
getExtensionsRegistry,
handleKeyboardActivation,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { css, styled, useTheme } from '@apache-superset/core/theme';
@@ -397,9 +396,6 @@ const ExploreChartPanel = ({
role="button"
tabIndex={0}
onClick={() => setShowDatasetModal(true)}
onKeyDown={handleKeyboardActivation(() =>
setShowDatasetModal(true),
)}
css={{ textDecoration: 'underline' }}
>
{t('Create a dataset')}
@@ -424,12 +420,7 @@ const ExploreChartPanel = ({
{t(
'You updated the values in the control panel, but the chart was not updated automatically. Run the query by clicking on the "Update chart" button or',
)}{' '}
<span
role="button"
tabIndex={0}
onClick={onQuery}
onKeyDown={handleKeyboardActivation(() => onQuery?.())}
>
<span role="button" tabIndex={0} onClick={onQuery}>
{t('click here')}
</span>
.

View File

@@ -37,7 +37,6 @@ import {
MatrixifyFormData,
DatasourceType,
ensureIsArray,
handleKeyboardActivation,
} from '@superset-ui/core';
import {
ControlStateMapping,
@@ -999,7 +998,6 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
tabIndex={0}
className="action-button"
onClick={toggleCollapse}
onKeyDown={handleKeyboardActivation(toggleCollapse)}
>
<Icons.VerticalAlignTopOutlined
iconSize="xl"
@@ -1025,7 +1023,6 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
<div
className="sidebar"
onClick={toggleCollapse}
onKeyDown={handleKeyboardActivation(toggleCollapse)}
data-test="open-datasource-tab"
role="button"
tabIndex={0}

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { useMemo, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { useTheme } from '@apache-superset/core/theme';
@@ -181,9 +180,6 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
},
}}
onClick={() => setShowAllColumns(!showAllColumns)}
onKeyDown={handleKeyboardActivation(() =>
setShowAllColumns(!showAllColumns),
)}
>
{showAllColumns ? (
<>

View File

@@ -35,7 +35,6 @@ import {
DatasourceType,
Metric,
QueryFormMetric,
handleKeyboardActivation,
} from '@superset-ui/core';
import { styled, css } from '@apache-superset/core/theme';
import { ColumnMeta, isSavedExpression } from '@superset-ui/chart-controls';
@@ -477,9 +476,6 @@ const ColumnSelectPopover = ({
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
onKeyDown={handleKeyboardActivation(
setDatasetAndClose,
)}
>
{t('Create a dataset')}
</span>{' '}
@@ -491,9 +487,6 @@ const ColumnSelectPopover = ({
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
onKeyDown={handleKeyboardActivation(
setDatasetAndClose,
)}
>
{t('Create a dataset')}
</span>{' '}
@@ -528,9 +521,6 @@ const ColumnSelectPopover = ({
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
onKeyDown={handleKeyboardActivation(
setDatasetAndClose,
)}
>
{t('Create a dataset')}
</span>{' '}

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { ChangeEvent, useCallback, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
@@ -97,7 +96,6 @@ export const DndColumnSelectPopoverTitle = ({
onMouseOut={onMouseOut}
onFocus={onMouseOver}
onClick={onClick}
onKeyDown={onClick ? handleKeyboardActivation(onClick) : undefined}
onBlur={onBlur}
role="button"
tabIndex={0}

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
import { Button } from '@superset-ui/core/components';
import { Tag } from 'src/components';
@@ -48,7 +47,6 @@ export const LayerTreeItem: FC<LayerTreeItemProps> = ({
<span
className="layer-tree-item-type"
onClick={onEditTag}
onKeyDown={handleKeyboardActivation(onEditTag)}
role="button"
tabIndex={0}
>
@@ -57,7 +55,6 @@ export const LayerTreeItem: FC<LayerTreeItemProps> = ({
<span
className="layer-tree-item-title"
onClick={onEditTag}
onKeyDown={handleKeyboardActivation(onEditTag)}
role="button"
tabIndex={0}
>

View File

@@ -19,12 +19,7 @@
/* eslint-disable camelcase */
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import {
isDefined,
ensureIsArray,
DatasourceType,
handleKeyboardActivation,
} from '@superset-ui/core';
import { isDefined, ensureIsArray, DatasourceType } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import type { editors } from '@apache-superset/core';
import { styled } from '@apache-superset/core/theme';
@@ -503,10 +498,6 @@ function AdhocMetricEditPopover({
handleDatasetModal?.(true);
onClose();
}}
onKeyDown={handleKeyboardActivation(() => {
handleDatasetModal?.(true);
onClose();
})}
>
{t('Create a dataset')}
</span>

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import {
ChangeEventHandler,
FocusEvent,
@@ -121,7 +120,6 @@ const AdhocMetricEditPopoverTitle: FC<AdhocMetricEditPopoverTitleProps> = ({
onMouseOut={handleMouseOut}
onFocus={handleMouseOver}
onClick={handleClick}
onKeyDown={handleKeyboardActivation(handleClick)}
onBlur={handleBlur}
role="button"
tabIndex={0}

Some files were not shown because too many files have changed in this diff Show More