mirror of
https://github.com/apache/superset.git
synced 2026-07-16 19:55:39 +00:00
Compare commits
2 Commits
chore/i18n
...
dashboard-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37cba307fb | ||
|
|
db55967301 |
1
.github/actions/file-changes-action
vendored
Submodule
1
.github/actions/file-changes-action
vendored
Submodule
Submodule .github/actions/file-changes-action added at a6ca26c142
2
.github/actions/setup-backend/action.yml
vendored
2
.github/actions/setup-backend/action.yml
vendored
@@ -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
|
||||
|
||||
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -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}}"
|
||||
|
||||
2
.github/workflows/embedded-sdk-release.yml
vendored
2
.github/workflows/embedded-sdk-release.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/embedded-sdk-test.yml
vendored
2
.github/workflows/embedded-sdk-test.yml
vendored
@@ -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"
|
||||
|
||||
85
.github/workflows/pre-commit.yml
vendored
85
.github/workflows/pre-commit.yml
vendored
@@ -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=$?
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/superset-docs-deploy.yml
vendored
2
.github/workflows/superset-docs-deploy.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/superset-docs-verify.yml
vendored
4
.github/workflows/superset-docs-verify.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/superset-e2e.yml
vendored
4
.github/workflows/superset-e2e.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/superset-playwright.yml
vendored
2
.github/workflows/superset-playwright.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/superset-translations.yml
vendored
2
.github/workflows/superset-translations.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/tech-debt.yml
vendored
2
.github/workflows/tech-debt.yml
vendored
@@ -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
3
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
125
docs/yarn.lock
125
docs/yarn.lock
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
287
superset-frontend/package-lock.json
generated
287
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>"{formatNumber(formatString, v)}"</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>"{formatNumber(formatString, v)}"</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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
|
||||
<a
|
||||
href="https://github.com/d3/d3-time-format#locale_format"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
D3 Time Format Reference
|
||||
</a>
|
||||
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
|
||||
<a
|
||||
href="https://github.com/d3/d3-time-format#locale_format"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
D3 Time Format Reference
|
||||
</a>
|
||||
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>"{formatTime(formatString, v)}"</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>"{formatTime(formatString, v)}"</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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'}`
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function sqlLabReducer(
|
||||
{
|
||||
hideLeftBar: action.hideLeftBar,
|
||||
},
|
||||
action.queryEditorId!,
|
||||
action.queryEditor!.id!,
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -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;
|
||||
`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,5 @@ export interface DatabaseSelectorProps {
|
||||
schema?: string;
|
||||
readOnly?: boolean;
|
||||
sqlLabMode?: boolean;
|
||||
compactMode?: boolean;
|
||||
onOpenModal?: () => void;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -137,7 +137,6 @@ export function saveChartCustomization(
|
||||
dispatch({
|
||||
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
|
||||
filterChanges: mergedResult,
|
||||
deletedIds,
|
||||
});
|
||||
|
||||
dispatch(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ export default function URLShortLinkButton({
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
>
|
||||
<CopyToClipboard
|
||||
text={shortUrl}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>{' '}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user