Compare commits

..

1 Commits

Author SHA1 Message Date
Elizabeth Thompson
9058b49971 fix(reports): commit permalink before Playwright navigates to tab URL
CreateDashboardPermalinkCommand only flushes the INSERT when invoked
inside an outer @transaction() (the nesting guard skips the commit).
The row is therefore not visible to Playwright's separate DB connection,
causing dashboard_permalink to return a 404, the <body class="standalone">
element to never appear, and the screenshot to time out after 600s.

Commit immediately after CreateDashboardPermalinkCommand.run() returns,
matching the pattern already used by create_log() in the same class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:50:30 +00:00
207 changed files with 10044 additions and 10215 deletions

View File

@@ -42,7 +42,7 @@ runs:
fi
echo "python-version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Python ${{ steps.set-python-version.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ steps.set-python-version.outputs.python-version }}
cache: ${{ inputs.cache }}

View File

@@ -3,6 +3,10 @@ enable-beta-ecosystems: true
updates:
- package-ecosystem: "github-actions"
directory: "/"
ignore:
# Ignore temporarily as release schedule is too mentally taxing for dep-handling maintainers
# Additionally, very few PRs are reviewed by this action.
- dependency-name: anthropics/claude-code-action
schedule:
interval: "daily"
cooldown:

88
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Claude PR Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: read
jobs:
check-permissions:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.check.outputs.allowed }}
steps:
- name: Check if user is allowed
id: check
env:
COMMENTER: ${{ github.event.comment.user.login }}
run: |
# List of allowed users
ALLOWED_USERS="mistercrunch,rusackas"
echo "Checking permissions for user: $COMMENTER"
# Check if user is in allowed list
if [[ ",$ALLOWED_USERS," == *",$COMMENTER,"* ]]; then
echo "allowed=true" >> $GITHUB_OUTPUT
echo "✅ User $COMMENTER is allowed to use Claude"
else
echo "allowed=false" >> $GITHUB_OUTPUT
echo "❌ User $COMMENTER is not allowed to use Claude"
fi
deny-access:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'false'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Comment access denied
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENTER_LOGIN: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
with:
script: |
const commenter = process.env.COMMENTER_LOGIN;
const message = `👋 Hi @${commenter}!
Thanks for trying to use Claude Code, but currently only certain team members have access to this feature.
If you believe you should have access, please contact a project maintainer.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
claude-code-action:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@5fb899572b81d2bb648d4d187173a2f423a9677c # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"

View File

@@ -75,24 +75,6 @@ jobs:
with:
persist-credentials: false
- name: Free up disk space
shell: bash
run: |
# Reclaim large preinstalled toolchains we don't use. The image
# build, and especially the docker-compose sanity check (which
# rebuilds from scratch whenever the registry cache image
# apache/superset-cache is unavailable), can otherwise exhaust the
# runner's root disk and fail with "no space left on device".
echo "Disk before cleanup:"; df -h /
sudo rm -rf \
/usr/share/dotnet \
/usr/local/lib/android \
/opt/ghc \
/usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL \
/usr/local/share/boost || true
echo "Disk after cleanup:"; df -h /
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
@@ -119,27 +101,13 @@ jobs:
PUSH_OR_LOAD="--load"
fi
# Retry to absorb transient Docker Hub registry errors (base-image
# pull timeouts, 504/401 on push, ECONNRESET) that otherwise fail
# the whole job. buildx reuses the buildkit layer cache from the
# failed attempt, so a retry mostly re-does just the failed push.
for attempt in 1 2 3; do
if supersetbot docker \
$PUSH_OR_LOAD \
--preset "$BUILD_PRESET" \
--context "$EVENT" \
--context-ref "$RELEASE" $FORCE_LATEST \
--extra-flags "--build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
$PLATFORM_ARG; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "::error::supersetbot docker build failed after 3 attempts"
exit 1
fi
echo "::warning::Build attempt ${attempt} failed; retrying in 30s..."
sleep 30
done
supersetbot docker \
$PUSH_OR_LOAD \
--preset "$BUILD_PRESET" \
--context "$EVENT" \
--context-ref "$RELEASE" $FORCE_LATEST \
--extra-flags "--build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
$PLATFORM_ARG
# in the context of push (using multi-platform build), we need to pull the image locally
- name: Docker pull
@@ -180,21 +148,6 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Free up disk space
shell: bash
run: |
# The sanity check rebuilds the image from scratch whenever the
# registry cache image apache/superset-cache is unavailable, which
# can exhaust the runner's root disk ("no space left on device").
echo "Disk before cleanup:"; df -h /
sudo rm -rf \
/usr/share/dotnet \
/usr/local/lib/android \
/opt/ghc \
/usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL \
/usr/local/share/boost || true
echo "Disk after cleanup:"; df -h /
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:

View File

@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: "temurin"
java-version: "11"

View File

@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: "temurin"
java-version: "11"

View File

@@ -71,7 +71,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: "zulu"
java-version: "21"

View File

@@ -1,11 +1,6 @@
# Python integration tests
name: Python-Integration
# Least-privilege default for GITHUB_TOKEN. Jobs that need more (e.g. OIDC for
# codecov uploads) opt in via their own job-level `permissions:` block.
permissions:
contents: read
on:
push:
branches:

View File

@@ -1,11 +1,6 @@
# Python unit tests
name: Python-Unit
# Least-privilege default for GITHUB_TOKEN. Jobs that need more (e.g. OIDC for
# codecov uploads) opt in via their own job-level `permissions:` block.
permissions:
contents: read
on:
push:
branches:

View File

@@ -396,10 +396,6 @@ categories:
url: https://www.techaudit.info
contributors: ["@ETselikov"]
- name: Tech Solution
url: https://www.tech-solution.com.ar/
contributors: ["@danteGiuliano", "@LeandroVallejos", "@McJaben", "@xJeree", "@zeo-return-null"]
- name: Tenable
url: https://www.tenable.com
contributors: ["@dflionis"]

View File

@@ -24,16 +24,6 @@ assists people when migrating to a new version.
## Next
### `thumbnail_url` removed from dashboard list API response
The `thumbnail_url` field has been removed from `GET /api/v1/dashboard/` list responses. External consumers relying on this field must now construct the thumbnail URL client-side using `id` and `changed_on_utc`:
```
/api/v1/dashboard/{id}/thumbnail/{changed_on_utc}/
```
The thumbnail endpoint redirects to the current digest URL regardless of whether the supplied digest is exact. If the image is not yet cached, that digest URL may return `202` and trigger async generation. Using `changed_on_utc` as the digest is sufficient for cache-busting purposes.
### Webhook alerts/reports block private/internal hosts by default
Webhook alert/report dispatch (`WebhookNotification.send`) now validates the target URL's host against the same private/internal-IP block applied to dataset import URLs. If the resolved host is in a loopback, link-local, private (RFC-1918), shared-CGNAT, or multicast range, the webhook is rejected with `NotificationParamException`.

View File

@@ -71,12 +71,12 @@ case "${1}" in
worker)
echo "Starting Celery worker..."
# setting up only 2 workers by default to contain memory usage in dev environments
celery --app=superset.tasks.celery_app:app worker -O fair -l INFO --concurrency=${CELERYD_CONCURRENCY:-2} ${WORKER_LOG_FILE:+--logfile=$WORKER_LOG_FILE}
celery --app=superset.tasks.celery_app:app worker -O fair -l INFO --concurrency=${CELERYD_CONCURRENCY:-2}
;;
beat)
echo "Starting Celery beat..."
rm -f /tmp/celerybeat.pid
celery --app=superset.tasks.celery_app:app beat --pidfile /tmp/celerybeat.pid -l INFO -s "${SUPERSET_HOME}"/celerybeat-schedule ${BEAT_LOG_FILE:+--logfile=$BEAT_LOG_FILE}
celery --app=superset.tasks.celery_app:app beat --pidfile /tmp/celerybeat.pid -l INFO -s "${SUPERSET_HOME}"/celerybeat-schedule
;;
app)
echo "Starting web app (using development server)..."

View File

@@ -22,24 +22,31 @@ level dependencies.
**Debian and Ubuntu**
The following command will ensure that the required dependencies are installed (tested on Ubuntu 20.04, 22.04, and 24.04):
Ubuntu **24.04** uses python 3.12 per default, which currently is not supported by Superset. You need to add a second python installation of 3.11 and install the required additional dependencies.
```bash
sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip python3-venv libsasl2-dev libldap2-dev libpq-dev default-libmysqlclient-dev pkg-config
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install python3.11 python3.11-dev python3.11-venv build-essential libssl-dev libffi-dev libsasl2-dev libldap2-dev default-libmysqlclient-dev
```
Refer to the
[pyproject.toml](https://github.com/apache/superset/blob/master/pyproject.toml) file for the list of
Python versions officially supported by Superset, and install a matching `python3` interpreter for
your distribution. The `libpq-dev` package is only needed if you intend to connect to (or use) a
PostgreSQL database; you can omit it otherwise.
In Ubuntu **20.04 and 22.04** the following command will ensure that the required dependencies are installed:
```bash
sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev
```
In Ubuntu **before 20.04** the following command will ensure that the required dependencies are installed:
```bash
sudo apt-get install build-essential libssl-dev libffi-dev python-dev python-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev
```
**Fedora and RHEL-derivative Linux distributions**
Install the following packages using the `yum` package manager:
```bash
sudo yum install gcc gcc-c++ libffi-devel python3-devel python3-pip python3-wheel openssl-devel cyrus-sasl-devel openldap-devel
sudo yum install gcc gcc-c++ libffi-devel python-devel python-pip python-wheel openssl-devel cyrus-sasl-devel openldap-devel
```
In more recent versions of CentOS and Fedora, you may need to install a slightly different set of packages using `dnf`:

View File

@@ -70,9 +70,9 @@
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.41",
"@swc/core": "^1.15.40",
"antd": "^6.4.3",
"baseline-browser-mapping": "^2.10.35",
"baseline-browser-mapping": "^2.10.34",
"caniuse-lite": "^1.0.30001797",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -101,15 +101,15 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.61.0",
"@typescript-eslint/parser": "^8.60.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.6.0",
"prettier": "^3.8.4",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.61.0",
"typescript-eslint": "^8.60.1",
"webpack": "^5.107.2"
},
"browserslist": {

View File

@@ -7235,10 +7235,10 @@
"pypi_packages": [
"oracledb"
],
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
"default_port": 1521,
"notes": "Previously used cx_Oracle, now uses oracledb.",
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
"docs_url": "https://cx-oracle.readthedocs.io/en/latest/user_guide/installation.html",
"category": "Other Databases"
},
"engine": "oracle",

View File

@@ -4143,86 +4143,86 @@
dependencies:
apg-lite "^1.0.4"
"@swc/core-darwin-arm64@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz#4fcbc9cbb9dfc9027d66e2b23b8d1d0315d164bd"
integrity sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==
"@swc/core-darwin-arm64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz#b05d715b04c4fd47baf59288233da85a683cc0bc"
integrity sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==
"@swc/core-darwin-x64@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz#726c60a893e2f1a07bee28f79b519b8e6489415b"
integrity sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==
"@swc/core-darwin-x64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz#3180daef5c1e47b435f8edd084509e0a5c0d883b"
integrity sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==
"@swc/core-linux-arm-gnueabihf@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz#08930e8015ca2fadc729546d5bd4b758a3999dda"
integrity sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==
"@swc/core-linux-arm-gnueabihf@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz#18fcd3c70e48fdfae07c9f18751b1409ce1e5e84"
integrity sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==
"@swc/core-linux-arm64-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz#6c27490a4013647a09ff64cea1d6b1169394602f"
integrity sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==
"@swc/core-linux-arm64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz#26304933922f2a8e3194770e404403fc25a19c89"
integrity sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==
"@swc/core-linux-arm64-musl@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz#4cce52fbbbe78b1f99c2a4e3f9ad2629f6eae494"
integrity sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==
"@swc/core-linux-arm64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz#3402dfba04ba7b8ea81f243e2f8fa2c336b54d03"
integrity sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==
"@swc/core-linux-ppc64-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz#3d1fadd8d320e7250a6b2a2d9c0b0d4dac162f97"
integrity sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==
"@swc/core-linux-ppc64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz#b3df9065cad352328c1eeef08a28fc9fe98785aa"
integrity sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==
"@swc/core-linux-s390x-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz#6e4c54168d4a8d7852ef797437bd25e6fb5d7a50"
integrity sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==
"@swc/core-linux-s390x-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz#58e5b601f641dde81b30626ef66a668701ec918f"
integrity sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==
"@swc/core-linux-x64-gnu@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz#5f947698786e15e2f696e0c6b3afd25138bae86b"
integrity sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==
"@swc/core-linux-x64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz#cf057dce0c148c53f2d30152baaf60ea29e5d59c"
integrity sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==
"@swc/core-linux-x64-musl@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz#f4a0910cb273e39bcc09d572a08f62a355a93628"
integrity sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==
"@swc/core-linux-x64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz#21fb1a4d0193e9bbcd1469ecd36166d2e96e4006"
integrity sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==
"@swc/core-win32-arm64-msvc@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz#a55334b1b7c23a962d4219f332b6422f3c3374e4"
integrity sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==
"@swc/core-win32-arm64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz#1dba23b2b0db86b3d6d65da2abd627cc607a1fbc"
integrity sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==
"@swc/core-win32-ia32-msvc@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz#e1135f8d6857f6c48e4bfb6105568b37b3f88dc5"
integrity sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==
"@swc/core-win32-ia32-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz#b2da1e33165d469467b1046a2189db468da488eb"
integrity sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==
"@swc/core-win32-x64-msvc@1.15.41":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz#52d241e2bf4c6154675c0ad447b29cbdb0ccb547"
integrity sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==
"@swc/core-win32-x64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz#3563f7e8ce8708f5fda43eb8e0956ef11e0da320"
integrity sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==
"@swc/core@^1.15.41", "@swc/core@^1.7.39":
version "1.15.41"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.41.tgz#a212c5040abd1ffd2ad6caf140f0d586ffcfaa6e"
integrity sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==
"@swc/core@^1.15.40", "@swc/core@^1.7.39":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.40.tgz#941c949aa88c0d8d291f102f519f3c2c77701b90"
integrity sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.26"
optionalDependencies:
"@swc/core-darwin-arm64" "1.15.41"
"@swc/core-darwin-x64" "1.15.41"
"@swc/core-linux-arm-gnueabihf" "1.15.41"
"@swc/core-linux-arm64-gnu" "1.15.41"
"@swc/core-linux-arm64-musl" "1.15.41"
"@swc/core-linux-ppc64-gnu" "1.15.41"
"@swc/core-linux-s390x-gnu" "1.15.41"
"@swc/core-linux-x64-gnu" "1.15.41"
"@swc/core-linux-x64-musl" "1.15.41"
"@swc/core-win32-arm64-msvc" "1.15.41"
"@swc/core-win32-ia32-msvc" "1.15.41"
"@swc/core-win32-x64-msvc" "1.15.41"
"@swc/core-darwin-arm64" "1.15.40"
"@swc/core-darwin-x64" "1.15.40"
"@swc/core-linux-arm-gnueabihf" "1.15.40"
"@swc/core-linux-arm64-gnu" "1.15.40"
"@swc/core-linux-arm64-musl" "1.15.40"
"@swc/core-linux-ppc64-gnu" "1.15.40"
"@swc/core-linux-s390x-gnu" "1.15.40"
"@swc/core-linux-x64-gnu" "1.15.40"
"@swc/core-linux-x64-musl" "1.15.40"
"@swc/core-win32-arm64-msvc" "1.15.40"
"@swc/core-win32-ia32-msvc" "1.15.40"
"@swc/core-win32-x64-msvc" "1.15.40"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -4922,110 +4922,110 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.61.0", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz#db20271974b94a3a54d3b9544e5f5b3481448400"
integrity sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==
"@typescript-eslint/eslint-plugin@8.60.1", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz#c1060bb8fa4be80624d3f3dec8dd9caca373af76"
integrity sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/type-utils" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/type-utils" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.61.0", "@typescript-eslint/parser@^8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.0.tgz#1afe73c9ccce16b7a26d6b95f9400b0ccc34af87"
integrity sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==
"@typescript-eslint/parser@8.60.1", "@typescript-eslint/parser@^8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
dependencies:
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
debug "^4.4.3"
"@typescript-eslint/project-service@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.0.tgz#417a2feac32e8ebd336d63f068c3b42b736ea1ac"
integrity sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==
"@typescript-eslint/project-service@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
integrity sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.61.0"
"@typescript-eslint/types" "^8.61.0"
"@typescript-eslint/tsconfig-utils" "^8.60.1"
"@typescript-eslint/types" "^8.60.1"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz#93c2520d05653fe65eb9ee98efc74fd0134a7852"
integrity sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==
"@typescript-eslint/scope-manager@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
integrity sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==
dependencies:
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/tsconfig-utils@8.61.0":
"@typescript-eslint/tsconfig-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
"@typescript-eslint/tsconfig-utils@^8.60.1":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba"
integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==
"@typescript-eslint/tsconfig-utils@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f"
integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==
"@typescript-eslint/type-utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz#50219b57e6b89cecfb1a15f093b15ec9ee019974"
integrity sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==
"@typescript-eslint/type-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
integrity sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==
dependencies:
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.61.0":
"@typescript-eslint/types@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
"@typescript-eslint/types@^8.60.1":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d"
integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==
"@typescript-eslint/types@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a"
integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==
"@typescript-eslint/typescript-estree@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz#98ca47260bbf627fc28f018b3a0abf00e3090690"
integrity sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==
"@typescript-eslint/typescript-estree@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
integrity sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==
dependencies:
"@typescript-eslint/project-service" "8.61.0"
"@typescript-eslint/tsconfig-utils" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
"@typescript-eslint/project-service" "8.60.1"
"@typescript-eslint/tsconfig-utils" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.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/utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.61.0.tgz#ed3546a052787e84ea6c5064d0919fc5eea8522f"
integrity sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==
"@typescript-eslint/utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
integrity sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/visitor-keys@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz#39b4e1ab8936d23bea973d39fd092f9aa21f275e"
integrity sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==
"@typescript-eslint/visitor-keys@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz#165d1d8901137b944efaf18f00ab5ecb57f06995"
integrity sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==
dependencies:
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/types" "8.60.1"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -5688,10 +5688,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.35, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.35"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz#f0f2232e0de2d2f82cc491bcf830b05ed05937c6"
integrity sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==
baseline-browser-mapping@^2.10.34, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.34"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz#dedb606362446777cfe328d30d4ee15056d06303"
integrity sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==
batch@0.6.1:
version "0.6.1"
@@ -12270,10 +12270,10 @@ prettier-linter-helpers@^1.0.1:
dependencies:
fast-diff "^1.1.2"
prettier@^3.8.4:
version "3.8.4"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.4.tgz#f334f013ac04a96676f24dabc23c1c4ae1bae411"
integrity sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==
prettier@^3.8.3:
version "3.8.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0"
integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==
pretty-error@^4.0.0:
version "4.0.0"
@@ -14499,15 +14499,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.61.0:
version "8.61.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.61.0.tgz#6927fb94f5f29623e370d33fd9fa61f15d6d996b"
integrity sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==
typescript-eslint@^8.60.1:
version "8.60.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.1.tgz#13db05c6eabb89669deec44545b788a0e9aee640"
integrity sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==
dependencies:
"@typescript-eslint/eslint-plugin" "8.61.0"
"@typescript-eslint/parser" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
"@typescript-eslint/eslint-plugin" "8.60.1"
"@typescript-eslint/parser" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
typescript@~6.0.3:
version "6.0.3"
@@ -15006,9 +15006,9 @@ webpack-dev-middleware@^7.4.2:
schema-utils "^4.0.0"
webpack-dev-server@^5.2.2:
version "5.2.5"
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz#648fceaac6a5736b0935e5c1e55d6aa1d0626119"
integrity sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==
version "5.2.4"
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz#6e6306ce59848ed322c235e48b326632b1eed6d6"
integrity sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==
dependencies:
"@types/bonjour" "^3.5.13"
"@types/connect-history-api-fallback" "^1.5.4"

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.16.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.16.2](https://img.shields.io/badge/Version-0.16.2-informational?style=flat-square)
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -126,7 +126,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetCeleryBeat.extraContainers }}
{{- tpl (toYaml .Values.supersetCeleryBeat.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetCeleryBeat.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -121,7 +121,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetCeleryFlower.extraContainers }}
{{- tpl (toYaml .Values.supersetCeleryFlower.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetCeleryFlower.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -141,7 +141,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetWorker.extraContainers }}
{{- tpl (toYaml .Values.supersetWorker.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetWorker.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -120,7 +120,7 @@ spec:
livenessProbe: {{- .Values.supersetWebsockets.livenessProbe | toYaml | nindent 12 }}
{{- end }}
{{- if .Values.supersetWebsockets.extraContainers }}
{{- tpl (toYaml .Values.supersetWebsockets.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetWebsockets.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -151,7 +151,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetNode.extraContainers }}
{{- tpl (toYaml .Values.supersetNode.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetNode.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -62,9 +62,6 @@ spec:
{{- if .Values.init.initContainers }}
initContainers: {{- tpl (toYaml .Values.init.initContainers) . | nindent 6 }}
{{- end }}
{{- with .Values.hostAliases }}
hostAliases: {{- toYaml . | nindent 6 }}
{{- end }}
containers:
- name: {{ template "superset.name" . }}-init-db
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
@@ -104,7 +101,7 @@ spec:
command: {{ tpl (toJson .Values.init.command) . }}
resources: {{- toYaml .Values.init.resources | nindent 10 }}
{{- if .Values.init.extraContainers }}
{{- tpl (toYaml .Values.init.extraContainers) . | nindent 6 }}
{{- toYaml .Values.init.extraContainers | nindent 6 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -43,9 +43,9 @@ dependencies = [
"click-option-group",
"colorama",
"flask-cors>=6.0.0, <7.0",
"croniter>=6.2.2",
"croniter>=0.3.28",
"cron-descriptor",
"cryptography>=48.0.0, <49.0.0",
"cryptography>=42.0.4, <47.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.1, <6.0.0",
@@ -53,7 +53,7 @@ dependencies = [
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
"flask-migrate>=4.1.0, <5.0",
"flask-migrate>=3.1.0, <5.0",
"flask-session>=0.4.0, <1.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
@@ -97,7 +97,7 @@ dependencies = [
"selenium>=4.44.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
"simplejson>=3.15.0",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
@@ -144,7 +144,7 @@ dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
excel = ["xlrd>=2.0.2, <2.1"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
@@ -156,7 +156,7 @@ firebird = ["sqlalchemy-firebird>=0.7.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.28.21", "sqlalchemy_hana==0.4.0"]
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
@@ -173,11 +173,11 @@ motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.1.0, <3"]
ocient = [
"sqlalchemy-ocient>=1.0.0",
"pyocient>=1.0.15, <4",
"pyocient>=1.0.15, <2",
"shapely",
"geojson",
]
oracle = ["oracledb>=2.0.0, <5"]
oracle = ["cx-Oracle>8.0.0, <8.4"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]

View File

@@ -18,30 +18,5 @@
testpaths =
tests
python_files = *_test.py test_*.py *_tests.py *viz/utils.py
# `-p no:warnings` temporarily disabled in favor of more finely tuned `filterwarnings`.
#addopts = -p no:warnings
addopts = -p no:warnings
asyncio_mode = auto
# `ignore` is effectively equivalent to `-p no:warnings`.
# Always print RemovedIn20Warning when SQLALCHEMY_WARN_20=1.
# Additionally, raise errors for refactored RemovedIn20Warning cases to prevent regression.
filterwarnings =
ignore
always::sqlalchemy.exc.RemovedIn20Warning
# error:Passing a string to Connection.execute\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
# error:"Query" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"SavedQuery" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"SqlaTable" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"SqlMetric" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"TableColumn" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:The ``as_declarative\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
# error:The autoload parameter is deprecated: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 `database` package is deprecated: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
# error:"User" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning

View File

@@ -26,7 +26,7 @@ filelock>=3.20.3,<4.0.0
brotli>=1.2.0,<2.0.0
numexpr>=2.9.0
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
cryptography>=48.0.0,<49.0.0
cryptography>=46.0.7,<47.0.0
# Security: Snyk - XSS vulnerability in Mako templates
mako>=1.3.11,<2.0.0
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers

View File

@@ -84,9 +84,9 @@ colorama==0.4.6
# flask-appbuilder
cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.2.2
croniter==6.0.0
# via apache-superset (pyproject.toml)
cryptography==48.0.1
cryptography==46.0.7
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -141,7 +141,7 @@ flask-login==0.6.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
flask-migrate==4.1.0
flask-migrate==3.1.0
# via apache-superset (pyproject.toml)
flask-session==0.8.0
# via apache-superset (pyproject.toml)
@@ -323,7 +323,7 @@ pyjwt==2.12.0
# redis
pynacl==1.6.2
# via paramiko
pyopenssl==26.2.0
pyopenssl==26.0.0
# via
# -r requirements/base.in
# shillelagh
@@ -344,6 +344,7 @@ python-dotenv==1.2.2
# via apache-superset (pyproject.toml)
pytz==2025.2
# via
# croniter
# flask-babel
# pandas
pyxlsb==1.0.10
@@ -383,7 +384,7 @@ setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.4
# via apache-superset (pyproject.toml)
simplejson==4.1.1
simplejson==3.20.1
# via apache-superset (pyproject.toml)
six==1.17.0
# via

View File

@@ -174,11 +174,11 @@ cron-descriptor==1.4.5
# via
# -c requirements/base-constraint.txt
# apache-superset
croniter==6.2.2
croniter==6.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
cryptography==48.0.1
cryptography==46.0.7
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -293,7 +293,7 @@ flask-login==0.6.3
# -c requirements/base-constraint.txt
# apache-superset
# flask-appbuilder
flask-migrate==4.1.0
flask-migrate==3.1.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -780,7 +780,7 @@ pynacl==1.6.2
# via
# -c requirements/base-constraint.txt
# paramiko
pyopenssl==26.2.0
pyopenssl==26.0.0
# via
# -c requirements/base-constraint.txt
# shillelagh
@@ -841,6 +841,7 @@ python-multipart==0.0.29
pytz==2025.2
# via
# -c requirements/base-constraint.txt
# croniter
# flask-babel
# pandas
# trino
@@ -938,7 +939,7 @@ shillelagh==1.4.4
# via
# -c requirements/base-constraint.txt
# apache-superset
simplejson==4.1.1
simplejson==3.20.1
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -1,6 +1,6 @@
{
"name": "@superset-ui/embedded-sdk",
"version": "0.4.0",
"version": "0.3.0",
"description": "SDK for embedding resources from Superset into your own application",
"access": "public",
"keywords": [

View File

@@ -107,13 +107,7 @@ module.exports = {
[
'babel-plugin-jsx-remove-data-test-id',
{
// The plugin matches attribute names exactly (no prefix match),
// so each data-test* attribute must be listed explicitly.
attributes: [
'data-test',
'data-test-drag-source-id',
'data-test-drop-target-id',
],
attributes: 'data-test',
},
],
],

File diff suppressed because it is too large Load Diff

View File

@@ -178,14 +178,14 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.2",
"fuse.js": "^7.4.1",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.7.0",
"google-auth-library": "^10.6.2",
"immer": "^11.1.8",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -203,13 +203,13 @@
"ol": "^10.9.0",
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
"react": "^18.2.0",
"react-arborist": "^3.10.1",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-dom": "^18.2.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.3",
"react-json-tree": "^0.20.0",
@@ -261,16 +261,16 @@
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.14",
"@formatjs/intl-durationformat": "^0.10.13",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.60.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.4.3",
"@storybook/addon-links": "10.4.3",
"@storybook/react-webpack5": "10.4.3",
"@storybook/addon-docs": "10.4.2",
"@storybook/addon-links": "10.4.2",
"@storybook/react-webpack5": "10.4.2",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.41",
"@swc/core": "^1.15.40",
"@swc/plugin-emotion": "^14.12.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
@@ -285,8 +285,8 @@
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.9.2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
@@ -344,19 +344,18 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.69.0",
"oxlint": "^1.68.0",
"po2json": "^0.4.5",
"prettier": "3.8.4",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^11.1.3",
"react-refresh": "^0.18.0",
"react-resizable": "^4.0.1",
"redux-mock-store": "^1.5.4",
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.4.3",
"storybook": "10.4.2",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
@@ -370,7 +369,7 @@
"webpack": "^5.107.2",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-dev-server": "^5.2.4",
"webpack-manifest-plugin": "^6.0.1",
"webpack-sources": "^3.5.0",
"webpack-visualizer-plugin2": "^2.0.0"

View File

@@ -37,7 +37,7 @@
"cross-env": "^10.1.0",
"fs-extra": "^11.3.5",
"jest": "^30.4.2",
"yeoman-test": "^11.5.3"
"yeoman-test": "^11.5.2"
},
"engines": {
"npm": ">= 4.0.0",

View File

@@ -97,8 +97,8 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"nanoid": "^5.0.9",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"lodash": "^4.18.1",

View File

@@ -16,26 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
export {}; // ensure this file is treated as a module so top-level declarations don't leak into global scope
type LoggingModule = typeof import('./index');
const loadLogging = (): LoggingModule['logging'] => {
let logging: LoggingModule['logging'] | undefined;
jest.isolateModules(() => {
({ logging } = jest.requireActual<LoggingModule>(
'@apache-superset/core/utils',
));
});
return logging!;
};
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
});
test('should pipe to `console` methods', () => {
const logging = loadLogging();
const { logging } = require('@apache-superset/core/utils');
jest.spyOn(logging, 'debug').mockImplementation();
jest.spyOn(logging, 'log').mockImplementation();
@@ -63,24 +50,20 @@ test('should pipe to `console` methods', () => {
});
test('should use noop functions when console unavailable', () => {
const originalConsole = window.console;
Object.assign(window, { console: undefined });
try {
const logging = loadLogging();
const { logging } = require('@apache-superset/core/utils');
expect(() => {
logging.debug();
logging.log();
logging.info();
logging.warn('warn');
logging.error('error');
logging.trace();
logging.table([
[1, 2],
[3, 4],
]);
}).not.toThrow();
} finally {
Object.assign(window, { console: originalConsole });
}
expect(() => {
logging.debug();
logging.log();
logging.info();
logging.warn('warn');
logging.error('error');
logging.trace();
logging.table([
[1, 2],
[3, 4],
]);
}).not.toThrow();
Object.assign(window, { console });
});

View File

@@ -40,9 +40,9 @@
"ace-builds": "^1.4.14",
"brace": "^0.11.1",
"memoize-one": "^5.1.1",
"react": "^18.3.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.3.0"
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -43,7 +43,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.9",
"dompurify": "^3.4.8",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
@@ -101,8 +101,8 @@
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loadable": "^5.5.0",
"tinycolor2": "*"
},

View File

@@ -17,23 +17,15 @@
* under the License.
*/
import { forwardRef } from 'react';
import { Avatar as AntdAvatar } from 'antd';
import type { AvatarProps, GroupProps as AvatarGroupProps } from './types';
export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>((props, ref) => (
<AntdAvatar ref={ref} {...props} />
));
export function Avatar(props: AvatarProps) {
return <AntdAvatar {...props} />;
}
// antd Avatar.Group is a plain function component without forwardRef; wrap in
// a span so this component can be a Tooltip / Popover trigger and skip the
// findDOMNode fallback.
export const AvatarGroup = forwardRef<HTMLSpanElement, AvatarGroupProps>(
(props, ref) => (
<span ref={ref}>
<AntdAvatar.Group {...props} />
</span>
),
);
export function AvatarGroup(props: AvatarGroupProps) {
return <AntdAvatar.Group {...props} />;
}
export type { AvatarProps, AvatarGroupProps };

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Children, ReactElement, Fragment, forwardRef, Ref } from 'react';
import { Children, ReactElement, Fragment } from 'react';
import cx from 'classnames';
import { Button as AntdButton } from 'antd';
import { useTheme } from '@apache-superset/core/theme';
@@ -100,7 +100,7 @@ const BUTTON_STYLE_MAP: Record<
link: { type: 'link' },
};
function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
export function Button(props: ButtonProps) {
const {
tooltip,
placement,
@@ -160,7 +160,6 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
const button = (
<AntdButton
ref={ref as Ref<HTMLButtonElement & HTMLAnchorElement>}
href={disabled ? undefined : href}
disabled={disabled}
type={antdType}
@@ -236,6 +235,4 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
return button;
}
export const Button = forwardRef<HTMLElement, ButtonProps>(ButtonInner);
export type { ButtonProps, OnClickHandler };

View File

@@ -75,10 +75,7 @@ export const DropdownButton = ({
id={`${kebabCase(tooltip)}-tooltip`}
title={tooltip}
>
{/* antd Dropdown.Button is a plain function component without
forwardRef; wrap in a span so the Tooltip can attach a ref to a
real DOM node and skip the findDOMNode fallback. */}
<span>{button}</span>
{button}
</Tooltip>
);
}

View File

@@ -240,10 +240,7 @@ export function EditableTitle({
t("You don't have the rights to alter this title.")
}
>
{/* Wrap in span so the Tooltip can attach a ref to a DOM element.
antd's Input.TextArea forwards a non-DOM imperative handle, which
triggers a React 18 findDOMNode deprecation warning. */}
<span>{titleComponent}</span>
{titleComponent}
</Tooltip>
);
}

View File

@@ -16,54 +16,47 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Tooltip } from '../Tooltip';
import { Button } from '../Button';
import type { IconTooltipProps } from './types';
export const IconTooltip = forwardRef<HTMLElement, IconTooltipProps>(
(
{
children = null,
className = '',
onClick = () => undefined,
placement = 'top',
style = {},
tooltip = null,
mouseEnterDelay = 0.3,
mouseLeaveDelay = 0.15,
},
ref,
) => {
const iconTooltip = (
<Button
ref={ref}
onClick={onClick}
style={{
padding: 0,
...style,
}}
buttonStyle="link"
className={`IconTooltip ${className}`}
export const IconTooltip = ({
children = null,
className = '',
onClick = () => undefined,
placement = 'top',
style = {},
tooltip = null,
mouseEnterDelay = 0.3,
mouseLeaveDelay = 0.15,
}: IconTooltipProps) => {
const iconTooltip = (
<Button
onClick={onClick}
style={{
padding: 0,
...style,
}}
buttonStyle="link"
className={`IconTooltip ${className}`}
>
{children}
</Button>
);
if (tooltip) {
return (
<Tooltip
id="tooltip"
title={tooltip}
placement={placement}
mouseEnterDelay={mouseEnterDelay}
mouseLeaveDelay={mouseLeaveDelay}
>
{children}
</Button>
{iconTooltip}
</Tooltip>
);
if (tooltip) {
return (
<Tooltip
id="tooltip"
title={tooltip}
placement={placement}
mouseEnterDelay={mouseEnterDelay}
mouseLeaveDelay={mouseLeaveDelay}
>
{iconTooltip}
</Tooltip>
);
}
return iconTooltip;
},
);
}
return iconTooltip;
};
export type { IconTooltipProps };

View File

@@ -165,7 +165,7 @@ import {
SlackOutlined,
ApiOutlined,
} from '@ant-design/icons';
import { ForwardRefExoticComponent, RefAttributes, forwardRef } from 'react';
import { FC } from 'react';
import { IconType } from './types';
import { BaseIconComponent } from './BaseIcon';
@@ -323,25 +323,19 @@ type AntdIconNames = keyof typeof AntdIcons;
export const antdEnhancedIcons: Record<
AntdIconNames,
ForwardRefExoticComponent<IconType & RefAttributes<HTMLSpanElement>>
FC<IconType>
> = Object.keys(AntdIcons)
.filter(key => !EXCLUDED_ICONS.some(excluded => key.includes(excluded)))
.reduce(
(acc, key) => {
acc[key as AntdIconNames] = forwardRef<HTMLSpanElement, IconType>(
(props, ref) => (
<BaseIconComponent
ref={ref}
component={AntdIcons[key as AntdIconNames]}
fileName={key}
{...props}
/>
),
acc[key as AntdIconNames] = (props: IconType) => (
<BaseIconComponent
component={AntdIcons[key as AntdIconNames]}
fileName={key}
{...props}
/>
);
return acc;
},
{} as Record<
AntdIconNames,
ForwardRefExoticComponent<IconType & RefAttributes<HTMLSpanElement>>
>,
{} as Record<AntdIconNames, FC<IconType>>,
);

View File

@@ -17,12 +17,12 @@
* under the License.
*/
import { FC, SVGProps, forwardRef, useEffect, useRef, useState } from 'react';
import { FC, SVGProps, useEffect, useRef, useState } from 'react';
import TransparentIcon from './svgs/transparent.svg';
import { IconType } from './types';
import { BaseIconComponent } from './BaseIcon';
const AsyncIcon = forwardRef<HTMLSpanElement, IconType>((props, ref) => {
const AsyncIcon = (props: IconType) => {
const [, setLoaded] = useState(false);
const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>>>();
const { fileName, customIcons, iconSize, iconColor, viewBox, ...restProps } =
@@ -46,7 +46,6 @@ const AsyncIcon = forwardRef<HTMLSpanElement, IconType>((props, ref) => {
return (
<BaseIconComponent
ref={ref}
component={ImportedSVG.current || TransparentIcon}
fileName={fileName}
customIcons={customIcons}
@@ -56,6 +55,6 @@ const AsyncIcon = forwardRef<HTMLSpanElement, IconType>((props, ref) => {
{...restProps}
/>
);
});
};
export default AsyncIcon;

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { forwardRef, type ComponentType } from 'react';
import { css, useTheme, getFontSize } from '@apache-superset/core/theme';
import { AntdIconType, BaseIconProps, CustomIconType, IconType } from './types';
@@ -36,78 +35,65 @@ const genAriaLabel = (fileName: string) => {
return name.toLowerCase();
};
export const BaseIconComponent = forwardRef<
HTMLSpanElement | SVGSVGElement,
export const BaseIconComponent: React.FC<
BaseIconProps & Omit<IconType, 'component'>
>(
(
{
component: Component,
iconColor,
iconSize,
viewBox,
customIcons,
fileName,
...rest
},
ref,
) => {
const theme = useTheme();
const whatRole = rest?.onClick ? 'button' : 'img';
const ariaLabel = genAriaLabel(fileName || '');
const style = {
color: iconColor,
fontSize: iconSize
? `${getFontSize(theme, iconSize)}px`
: `${theme.fontSize}px`,
cursor: rest?.onClick ? 'pointer' : undefined,
};
> = ({
component: Component,
iconColor,
iconSize,
viewBox,
customIcons,
fileName,
...rest
}) => {
const theme = useTheme();
const whatRole = rest?.onClick ? 'button' : 'img';
const ariaLabel = genAriaLabel(fileName || '');
const style = {
color: iconColor,
fontSize: iconSize
? `${getFontSize(theme, iconSize)}px`
: `${theme.fontSize}px`,
cursor: rest?.onClick ? 'pointer' : undefined,
};
const AntdComponent = Component as ComponentType<
Record<string, unknown> & {
ref?: React.Ref<HTMLSpanElement | SVGSVGElement>;
}
>;
return customIcons ? (
<span
ref={ref as React.Ref<HTMLSpanElement>}
role={whatRole}
aria-label={ariaLabel}
data-test={ariaLabel}
css={[
css`
display: inline-flex;
align-items: center;
line-height: 0;
vertical-align: middle;
`,
]}
>
<Component
viewBox={viewBox || '0 0 24 24'}
style={style}
width={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
height={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
{...(rest as CustomIconType)}
/>
</span>
) : (
<AntdComponent
ref={ref}
role={whatRole}
return customIcons ? (
<span
role={whatRole}
aria-label={ariaLabel}
data-test={ariaLabel}
css={[
css`
display: inline-flex;
align-items: center;
line-height: 0;
vertical-align: middle;
`,
]}
>
<Component
viewBox={viewBox || '0 0 24 24'}
style={style}
aria-label={ariaLabel}
data-test={ariaLabel}
{...(rest as AntdIconType)}
width={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
height={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
{...(rest as CustomIconType)}
/>
);
},
);
</span>
) : (
<Component
role={whatRole}
style={style}
aria-label={ariaLabel}
data-test={ariaLabel}
{...(rest as AntdIconType)}
/>
);
};

View File

@@ -17,16 +17,12 @@
* under the License.
*/
import { ForwardRefExoticComponent, RefAttributes, forwardRef } from 'react';
import { FC } from 'react';
import { antdEnhancedIcons } from './AntdEnhanced';
import AsyncIcon from './AsyncIcon';
import type { IconType } from './types';
type IconComponent = ForwardRefExoticComponent<
IconType & RefAttributes<HTMLSpanElement>
>;
/**
* Filename is going to be inferred from the icon name.
* i.e. BigNumberChartTile => assets/images/icons/big_number_chart_tile
@@ -62,17 +58,15 @@ const customIcons = [
'Undo',
] as const;
type CustomIconType = Record<(typeof customIcons)[number], IconComponent>;
type CustomIconType = Record<(typeof customIcons)[number], FC<IconType>>;
const iconOverrides: CustomIconType = {} as CustomIconType;
customIcons.forEach(customIcon => {
const fileName = customIcon
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase();
iconOverrides[customIcon] = forwardRef<HTMLSpanElement, IconType>(
(props, ref) => (
<AsyncIcon ref={ref} customIcons fileName={fileName} {...props} />
),
iconOverrides[customIcon] = (props: IconType) => (
<AsyncIcon customIcons fileName={fileName} {...props} />
);
});
@@ -80,7 +74,7 @@ export type IconNameType =
| keyof typeof antdEnhancedIcons
| keyof typeof iconOverrides;
type IconComponentType = Record<IconNameType, IconComponent>;
type IconComponentType = Record<IconNameType, FC<IconType>>;
export const Icons: IconComponentType = {
...antdEnhancedIcons,

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Tag } from '@superset-ui/core/components/Tag';
import { css } from '@emotion/react';
import { useTheme, getColorVariants } from '@apache-superset/core/theme';
@@ -24,7 +23,7 @@ import { DatasetTypeLabel } from './reusable/DatasetTypeLabel';
import { PublishedLabel } from './reusable/PublishedLabel';
import type { LabelProps } from './types';
export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
export function Label(props: LabelProps) {
const theme = useTheme();
// Use Ant Design's motion duration instead of deprecated transitionTiming
const {
@@ -72,7 +71,6 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
return (
<Tag
ref={ref}
onClick={onClick}
role={onClick ? 'button' : undefined}
style={style}
@@ -83,6 +81,6 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
{children}
</Tag>
);
});
}
export { DatasetTypeLabel, PublishedLabel };
export type { LabelType } from './types';

View File

@@ -371,9 +371,6 @@ const CustomModal = ({
disabled={!draggable || dragDisabled}
bounds={bounds ?? false}
onStart={(event, uiData) => onDragStart(event, uiData)}
// Pass nodeRef so react-draggable does not fall back to
// ReactDOM.findDOMNode (deprecated in React 18+ Strict Mode).
nodeRef={draggableRef}
{...draggableConfig}
>
{resizable ? (

View File

@@ -16,15 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Popover as AntdPopover } from 'antd';
import { PopoverProps as AntdPopoverProps } from 'antd/es/popover';
import type { TooltipRef } from 'antd/es/tooltip';
export interface PopoverProps extends AntdPopoverProps {
forceRender?: boolean;
}
export const Popover = forwardRef<TooltipRef, PopoverProps>((props, ref) => (
<AntdPopover ref={ref} {...props} />
));
export const Popover = (props: PopoverProps) => <AntdPopover {...props} />;

View File

@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { MouseEventHandler } from 'react';
import { MouseEventHandler, forwardRef } from 'react';
import { SupersetTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import type { IconType } from '@superset-ui/core/components/Icons/types';
import { Tooltip } from '../Tooltip';
export interface RefreshLabelProps {
@@ -31,19 +32,25 @@ const RefreshLabel = ({
onClick,
tooltipContent,
disabled,
}: RefreshLabelProps) => (
<Tooltip title={tooltipContent}>
<Icons.SyncOutlined
iconSize="l"
role="button"
onClick={disabled ? undefined : onClick}
css={(theme: SupersetTheme) => ({
cursor: 'pointer',
color: theme.colorIcon,
'&:hover': { color: theme.colorPrimary },
})}
/>
</Tooltip>
);
}: RefreshLabelProps) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const IconWithoutRef = forwardRef((props: IconType, ref: any) => (
<Icons.SyncOutlined iconSize="l" {...props} />
));
return (
<Tooltip title={tooltipContent}>
<IconWithoutRef
role="button"
onClick={disabled ? undefined : onClick}
css={(theme: SupersetTheme) => ({
cursor: 'pointer',
color: theme.colorIcon,
'&:hover': { color: theme.colorPrimary },
})}
/>
</Tooltip>
);
};
export default RefreshLabel;

View File

@@ -16,22 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Tooltip as AntdTooltip } from 'antd';
import type { TooltipRef } from 'antd/es/tooltip';
import type { TooltipProps, TooltipPlacement } from './types';
export const Tooltip = forwardRef<TooltipRef, TooltipProps>(
({ overlayStyle, ...props }, ref) => (
<AntdTooltip
ref={ref}
styles={{
body: { overflow: 'hidden', textOverflow: 'ellipsis' },
root: overlayStyle ?? {},
}}
{...props}
/>
),
export const Tooltip = ({ overlayStyle, ...props }: TooltipProps) => (
<AntdTooltip
styles={{
body: { overflow: 'hidden', textOverflow: 'ellipsis' },
root: overlayStyle ?? {},
}}
{...props}
/>
);
export type { TooltipProps, TooltipPlacement };

View File

@@ -33,7 +33,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -34,6 +34,6 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -31,7 +31,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -31,7 +31,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -36,6 +36,6 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -33,8 +33,8 @@
"@apache-superset/core": "*",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -32,7 +32,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -39,6 +39,6 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.4.9",
"dompurify": "^3.4.8",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
@@ -43,6 +43,6 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.21",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -44,8 +44,8 @@
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "*",
"@types/react": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -47,7 +47,7 @@
"geostyler-wfs-parser": "^3.0.1",
"ol": "^10.8.0",
"polished": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

View File

@@ -38,7 +38,7 @@
"dayjs": "^1.11.21",
"echarts": "*",
"memoize-one": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -39,9 +39,9 @@
"handlebars": "^4.7.8",
"lodash": "^4.18.1",
"dayjs": "^1.11.21",
"react": "^18.3.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.3.0"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/jest": "^30.0.0",

View File

@@ -23,7 +23,7 @@ import {
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { validateNonEmpty } from '@superset-ui/core';
import { useTheme, useThemeMode } from '@apache-superset/core/theme';
import { useTheme } from '@apache-superset/core/theme';
import { InfoTooltip } from '@superset-ui/core/components';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
@@ -37,7 +37,6 @@ const HandlebarsTemplateControl = (
props: CustomControlConfig<HandlebarsCustomControlProps>,
) => {
const theme = useTheme();
const isDarkMode = useThemeMode();
const val = String(
props?.value ? props?.value : props?.default ? props?.default : '',
);
@@ -66,7 +65,7 @@ const HandlebarsTemplateControl = (
</div>
</ControlHeader>
<CodeEditor
theme={isDarkMode ? 'dark' : 'light'}
theme="dark"
value={val}
onChange={source => {
debounceFunc(props.onChange, source || '');

View File

@@ -22,7 +22,7 @@ import {
sharedControls,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { useTheme, useThemeMode } from '@apache-superset/core/theme';
import { useTheme } from '@apache-superset/core/theme';
import { InfoTooltip } from '@superset-ui/core/components';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
@@ -35,7 +35,6 @@ interface StyleCustomControlProps {
const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
const theme = useTheme();
const isDarkMode = useThemeMode();
const htmlSanitization = props.htmlSanitization ?? true;
const defaultValue = props?.value
@@ -64,7 +63,7 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
</div>
</ControlHeader>
<CodeEditor
theme={isDarkMode ? 'dark' : 'light'}
theme="dark"
mode="css"
value={props.value}
defaultValue={defaultValue}

View File

@@ -1,77 +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 { ComponentType } from 'react';
import { CustomControlItem } from '@superset-ui/chart-controls';
import { render } from 'spec/helpers/testing-library';
import { useThemeMode } from '@apache-superset/core/theme';
import { handlebarsTemplateControlSetItem } from '../../src/plugin/controls/handlebarTemplate';
import { styleControlSetItem } from '../../src/plugin/controls/style';
const mockCodeEditor = jest.fn((_props: { theme?: string }) => null);
jest.mock('../../src/components/CodeEditor/CodeEditor', () => ({
CodeEditor: (props: { theme?: string }) => mockCodeEditor(props),
}));
jest.mock('@apache-superset/core/theme', () => ({
...jest.requireActual('@apache-superset/core/theme'),
useThemeMode: jest.fn(),
}));
const HandlebarsTemplateControl = (
handlebarsTemplateControlSetItem as CustomControlItem
).config.type as ComponentType<{ value: string; onChange: () => void }>;
const StyleControl = (styleControlSetItem as CustomControlItem).config
.type as ComponentType<{ value: string; onChange: () => void }>;
const mockedUseThemeMode = useThemeMode as jest.Mock;
afterEach(() => jest.clearAllMocks());
test('Handlebars Template editor uses the light Ace theme in a light UI', () => {
mockedUseThemeMode.mockReturnValue(false);
render(<HandlebarsTemplateControl value="x" onChange={jest.fn()} />);
expect(mockCodeEditor).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'light' }),
);
});
test('Handlebars Template editor uses the dark Ace theme in a dark UI', () => {
mockedUseThemeMode.mockReturnValue(true);
render(<HandlebarsTemplateControl value="x" onChange={jest.fn()} />);
expect(mockCodeEditor).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'dark' }),
);
});
test('CSS Styles editor uses the light Ace theme in a light UI', () => {
mockedUseThemeMode.mockReturnValue(false);
render(<StyleControl value="x" onChange={jest.fn()} />);
expect(mockCodeEditor).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'light' }),
);
});
test('CSS Styles editor uses the dark Ace theme in a dark UI', () => {
mockedUseThemeMode.mockReturnValue(true);
render(<StyleControl value="x" onChange={jest.fn()} />);
expect(mockCodeEditor).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'dark' }),
);
});

View File

@@ -33,8 +33,8 @@
"@superset-ui/core": "*",
"lodash": "^4.18.1",
"prop-types": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/types": "^7.29.7",

View File

@@ -36,8 +36,8 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -45,8 +45,8 @@
"@testing-library/user-event": "*",
"@types/react": "*",
"match-sorter": "^8.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -21,7 +21,6 @@ import { css, styled } from '@apache-superset/core/theme';
export default styled.div`
${({ theme }) => css`
/* Base table styles */
padding: ${theme.sizeUnit * 5}px;
table {
width: 100%;
min-width: auto;

View File

@@ -1613,8 +1613,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
pageSize={pageSize}
serverPaginationData={serverPaginationData}
pageSizeOptions={pageSizeOptions}
width={Math.max(0, widthFromState - theme.sizeUnit * 10)}
height={Math.max(0, heightFromState - theme.sizeUnit * 10)}
width={widthFromState}
height={heightFromState}
serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange}
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}

View File

@@ -39,7 +39,7 @@
"@superset-ui/core": "*",
"@types/lodash": "*",
"@types/react": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"devDependencies": {
"@types/d3-cloud": "^1.2.9"

View File

@@ -67,8 +67,8 @@
"@superset-ui/core": "*",
"dayjs": "^1.11.21",
"mapbox-gl": ">=1.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"mapbox-gl": {

View File

@@ -17,11 +17,7 @@
* under the License.
*/
import type { ReactElement } from 'react';
import type {
ControlPanelSectionConfig,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { isCustomControlItem } from '@superset-ui/chart-controls';
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render } from '@testing-library/react';
import { SqlaFormData } from '@superset-ui/core';
@@ -32,7 +28,6 @@ import DeckGLGeoJson, {
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
getPoints,
getLayer,
} from './Geojson';
import controlPanel from './controlPanel';
@@ -300,158 +295,3 @@ test('DeckGLGeoJson falls back to legacy map_style when provider-specific style
}),
);
});
const baseFormData: SqlaFormData = {
datasource: 'test_datasource',
viz_type: 'deck_geojson',
slice_id: 1,
fill_color_picker: { r: 0, g: 0, b: 255, a: 1 },
stroke_color_picker: { r: 0, g: 0, b: 0, a: 1 },
};
const baseLayerArgs = {
onContextMenu: jest.fn(),
filterState: undefined,
setDataMask: jest.fn(),
payload: { data: { type: 'FeatureCollection', features: [] } },
setTooltip: jest.fn(),
emitCrossFilters: false,
};
test('getLayer preserves rendering for existing charts without new point radius fields', () => {
// Simulate form data from an existing chart that only has point_radius_scale
const legacyFormData = {
...baseFormData,
point_radius_scale: 200,
// point_radius and point_radius_units intentionally absent
};
const layer = getLayer({ formData: legacyFormData, ...baseLayerArgs });
const { props } = layer;
// Should match deck.gl defaults, NOT the new control panel defaults
expect(props.getPointRadius).toBe(1); // deck.gl default, not 10
expect(props.pointRadiusUnits).toBe('meters'); // deck.gl default, not 'pixels'
expect(props.pointRadiusScale).toBe(200); // user's saved value preserved
});
test('getLayer uses control panel defaults for new charts', () => {
const newChartFormData = {
...baseFormData,
point_radius: 10,
point_radius_units: 'pixels',
point_radius_scale: 1,
};
const layer = getLayer({ formData: newChartFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(10);
expect(props.pointRadiusUnits).toBe('pixels');
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer falls back to defaults when legacy fields are null', () => {
// The old point_radius_scale control had `default: null`, so legacy charts
// can have null persisted; it must fall back to 1, not coerce to 0.
const nullFormData = {
...baseFormData,
point_radius: null,
point_radius_scale: null,
};
const layer = getLayer({ formData: nullFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(1);
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer preserves an explicit zero radius scale', () => {
const zeroFormData = {
...baseFormData,
point_radius_scale: 0,
};
const layer = getLayer({ formData: zeroFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.pointRadiusScale).toBe(0);
});
test('getLayer coerces free-form string radius values to numbers', () => {
// Free-form SelectControls can store user-typed values as strings
const stringFormData = {
...baseFormData,
point_radius: '3',
point_radius_scale: '0.25',
};
const layer = getLayer({ formData: stringFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(3);
expect(props.pointRadiusScale).toBe(0.25);
});
type ControlConfig = {
default?: unknown;
validators?: unknown[];
choices?: [unknown, unknown][];
renderTrigger?: boolean;
};
const controlItems = controlPanel.controlPanelSections
.filter(
(s: ControlPanelSectionConfig | null): s is ControlPanelSectionConfig =>
s !== null,
)
.flatMap((section: ControlPanelSectionConfig) => section.controlSetRows)
.flat();
const findControlConfig = (name: string): ControlConfig | undefined =>
(controlItems.filter(isCustomControlItem) as CustomControlItem[]).find(
(item: CustomControlItem) => item.name === name,
)?.config as ControlConfig | undefined;
test('controlPanel exposes a Point Radius control defaulting to 10', () => {
const config = findControlConfig('point_radius');
expect(config).toBeDefined();
expect(config?.default).toBe(10);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[1, '1'],
[10, '10'],
[100, '100'],
]),
);
});
test('controlPanel Point Radius Scale defaults to 1 with fractional choices', () => {
const config = findControlConfig('point_radius_scale');
expect(config).toBeDefined();
expect(config?.default).toBe(1);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[0.1, '0.1'],
[1, '1'],
[10, '10'],
]),
);
});
test('controlPanel Point Radius Units defaults to pixels', () => {
const config = findControlConfig('point_radius_units');
expect(config).toBeDefined();
expect(config?.default).toBe('pixels');
expect(config?.renderTrigger).toBe(true);
expect(config?.choices?.map(([value]) => value)).toEqual([
'pixels',
'meters',
'common',
]);
});

View File

@@ -254,15 +254,6 @@ export const computeGeoJsonIconOptionsFromFormData = (
iconSizeUnits: fd.icon_size_unit,
});
// Free-form SelectControls can yield string values, and legacy charts may have
// null persisted for these fields, so coerce to a number (falling back to the
// provided default for null/undefined/NaN input, while preserving an explicit 0)
// before handing them to deck.gl's numeric layer props.
const toNumber = (value: unknown, fallback: number) => {
const num = Number(value ?? fallback);
return Number.isFinite(num) ? num : fallback;
};
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
formData,
onContextMenu,
@@ -337,11 +328,7 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
getFillColor(feature, filterState?.value),
getLineColor,
getLineWidth: fd.line_width || 1,
// Use deck.gl defaults as fallbacks for backward compatibility with existing charts.
// New charts will get control panel defaults (point_radius=10, units='pixels', scale=1).
getPointRadius: toNumber(fd.point_radius, 1),
pointRadiusUnits: fd.point_radius_units ?? 'meters',
pointRadiusScale: toNumber(fd.point_radius_scale, 1),
pointRadiusScale: fd.point_radius_scale,
lineWidthUnits: fd.line_width_unit,
pointType,
...labelOpts,

View File

@@ -22,8 +22,6 @@ import {
legacyValidateInteger,
isFeatureEnabled,
FeatureFlag,
validateNumber,
validateInteger,
} from '@superset-ui/core';
import { formatSelectOptions } from '../../utilities/utils';
import {
@@ -354,56 +352,15 @@ const config: ControlPanelConfig = {
},
],
[
{
name: 'point_radius',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius'),
description: t(
'The radius of point features, in the units specified below. ' +
'The final rendered size is this value multiplied by Point Radius Scale.',
),
validators: [validateInteger],
default: 10,
choices: formatSelectOptions([1, 5, 10, 20, 50, 100]),
renderTrigger: true,
},
},
{
name: 'point_radius_scale',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius Scale'),
description: t(
'A multiplier applied to the point radius. ' +
'Use this to uniformly scale all points.',
),
validators: [validateNumber],
default: 1,
choices: formatSelectOptions([0.1, 0.5, 1, 2, 5, 10]),
renderTrigger: true,
},
},
],
[
{
name: 'point_radius_units',
config: {
type: 'SelectControl',
label: t('Point Radius Units'),
description: t(
'The unit for point radius. Use "pixels" for consistent ' +
'screen-space sizing regardless of zoom level.',
),
default: 'pixels',
choices: [
['pixels', t('Pixels')],
['meters', t('Meters')],
['common', t('Common (unit per pixel at zoom 0)')],
],
renderTrigger: true,
validators: [legacyValidateInteger],
default: null,
choices: formatSelectOptions([0, 100, 200, 300, 500]),
},
},
],

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { AriaAttributes, Ref } from 'react';
import { AriaAttributes } from 'react';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import jQuery from 'jquery';
@@ -98,39 +98,31 @@ jest.mock('rehype-raw', () => () => jest.fn());
// Tests should override this when needed
jest.mock('@superset-ui/core/components/Icons/AsyncIcon', () => ({
__esModule: true,
// eslint-disable-next-line global-require
default: require('react').forwardRef(
(
{
fileName,
role,
'aria-label': ariaLabel,
onClick,
...rest
}: {
fileName: string;
role?: string;
'aria-label'?: AriaAttributes['aria-label'];
onClick?: () => void;
},
ref: Ref<HTMLSpanElement>,
) => {
// Simple mock that provides the essential attributes for testing
const label =
ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || '';
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
ref={ref}
role={role || (onClick ? 'button' : 'img')}
aria-label={label}
data-test={label}
onClick={onClick}
{...rest}
/>
);
},
),
default: ({
fileName,
role,
'aria-label': ariaLabel,
onClick,
...rest
}: {
fileName: string;
role?: string;
'aria-label'?: AriaAttributes['aria-label'];
onClick?: () => void;
}) => {
// Simple mock that provides the essential attributes for testing
const label = ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || '';
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
role={role || (onClick ? 'button' : 'img')}
aria-label={label}
data-test={label}
onClick={onClick}
{...rest}
/>
);
},
StyledIcon: ({
component: Component,
role,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { act } from 'react';
import { act } from 'react-dom/test-utils';
import { QueryState } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';

View File

@@ -189,19 +189,15 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
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>
<DatabaseSelector
key={`db-selector-${db ? db.id : 'no-db'}:${catalog ?? 'no-catalog'}:${
schema ?? 'no-schema'
}`}
{...dbSelectorProps}
emptyState={<EmptyState />}
sqlLabMode
onOpenModal={openSelectorModal}
/>
</Popover>
<StyledDivider />
<TableExploreTree queryEditorId={activeQEId} />

View File

@@ -0,0 +1,464 @@
/**
* 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 { isValidElement } from 'react';
import fetchMock from 'fetch-mock';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import TableElement, { Column } from 'src/SqlLab/components/TableElement';
import { table, initialState } from 'src/SqlLab/fixtures';
import { render, waitFor, fireEvent } from 'spec/helpers/testing-library';
import * as sqlLabActions from 'src/SqlLab/actions/sqlLab';
import { QueryEditor } from 'src/SqlLab/types';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
jest.mock('@superset-ui/core/components/Loading', () => ({
Loading: () => <div data-test="mock-loading" />,
}));
jest.mock('@superset-ui/core/components/IconTooltip', () => ({
IconTooltip: ({
onClick,
tooltip,
}: {
onClick: () => void;
tooltip: string;
}) => (
<button type="button" data-test="mock-icon-tooltip" onClick={onClick}>
{tooltip}
</button>
),
}));
jest.mock(
'src/SqlLab/components/ColumnElement',
() =>
({ column }: { column: Column }) => (
<div data-test="mock-column-element">{column.name}</div>
),
);
const getTableMetadataEndpoint =
/\/api\/v1\/database\/\d+\/table_metadata\/(?:\?.*)?$/;
const getExtraTableMetadataEndpoint =
/\/api\/v1\/database\/\d+\/table_metadata\/extra\/(?:\?.*)?$/;
const updateTableSchemaExpandedEndpoint = 'glob:*/tableschemaview/*/expanded';
const updateTableSchemaEndpoint = 'glob:*/tableschemaview/';
beforeEach(() => {
fetchMock.get(getTableMetadataEndpoint, table);
fetchMock.get(getExtraTableMetadataEndpoint, {});
fetchMock.post(updateTableSchemaExpandedEndpoint, {});
fetchMock.post(updateTableSchemaEndpoint, {});
});
afterEach(() => fetchMock.clearHistory().removeRoutes());
const mockedProps = {
table: {
...table,
initialized: true,
},
activeKey: [table.id],
};
const createStateWithQueryEditor = (queryEditor: Partial<QueryEditor>) => ({
...initialState,
sqlLab: {
...initialState.sqlLab,
queryEditors: [queryEditor],
},
});
const setupSyncTableTest = () => {
const spy = jest.spyOn(sqlLabActions, 'syncTable');
mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
fetchMock.removeRoute(updateTableSchemaEndpoint);
fetchMock.post(
updateTableSchemaEndpoint,
{ id: 100 },
{ name: updateTableSchemaEndpoint },
);
return spy;
};
test('renders', () => {
expect(isValidElement(<TableElement table={table} />)).toBe(true);
});
test('renders with props', () => {
expect(isValidElement(<TableElement {...mockedProps} />)).toBe(true);
});
test('has 4 IconTooltip elements', async () => {
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
});
test('has 14 columns', async () => {
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(getAllByTestId('mock-column-element')).toHaveLength(14),
);
});
test('fades table', async () => {
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
const style = window.getComputedStyle(getAllByTestId('fade')[0]);
expect(style.opacity).toBe('0');
fireEvent.mouseEnter(getAllByTestId('table-element-header-container')[0]);
await waitFor(() =>
expect(window.getComputedStyle(getAllByTestId('fade')[0]).opacity).toBe(
'1',
),
);
});
test('sorts columns', async () => {
const { getAllByTestId, getByText } = render(
<TableElement {...mockedProps} />,
{
useRedux: true,
initialState,
},
);
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
expect(
getAllByTestId('mock-column-element').map(el => el.textContent),
).toEqual(table.columns.map(col => col.name));
fireEvent.click(getByText('Sort columns alphabetically'));
const sorted = table.columns.map(col => col.name).sort();
expect(
getAllByTestId('mock-column-element').map(el => el.textContent),
).toEqual(sorted);
expect(getAllByTestId('mock-column-element')[0]).toHaveTextContent('active');
});
test('removes the table', async () => {
const updateTableSchemaEndpoint = 'glob:*/tableschemaview/*';
fetchMock.delete(updateTableSchemaEndpoint, {});
mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
const { getAllByTestId, getByText } = render(
<TableElement {...mockedProps} />,
{
useRedux: true,
initialState,
},
);
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
0,
);
fireEvent.click(getByText('Remove table preview'));
await waitFor(() =>
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
1,
),
);
mockedIsFeatureEnabled.mockClear();
});
test('fetches table metadata when expanded', async () => {
render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(0);
expect(
fetchMock.callHistory.calls(getExtraTableMetadataEndpoint),
).toHaveLength(0);
await waitFor(() =>
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
1,
),
);
expect(
fetchMock.callHistory.calls(updateTableSchemaExpandedEndpoint),
).toHaveLength(0);
expect(
fetchMock.callHistory.calls(getExtraTableMetadataEndpoint),
).toHaveLength(1);
});
test('refreshes table metadata when triggered', async () => {
const { getAllByTestId, getByText } = render(
<TableElement {...mockedProps} />,
{
useRedux: true,
initialState,
},
);
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
0,
);
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(1);
fireEvent.click(getByText('Refresh table schema'));
await waitFor(() =>
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
2,
),
);
await waitFor(() =>
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
1,
),
);
});
test('calls syncTable with valid backend ID when query editor has tabViewId', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'temp-id-123',
};
const state = createStateWithQueryEditor({
id: 'temp-id-123',
tabViewId: '42',
inLocalStorage: false,
name: 'Test Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(syncTableSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
'42', // finalQueryEditorId
);
});
syncTableSpy.mockRestore();
});
test('does not call syncTable when query editor is in localStorage', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'local-id',
};
const state = createStateWithQueryEditor({
id: 'local-id',
tabViewId: undefined,
inLocalStorage: true,
name: 'Local Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
1,
);
});
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
syncTableSpy.mockRestore();
});
test('does not call syncTable with non-numeric queryEditorId', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'not-a-number',
};
const state = createStateWithQueryEditor({
id: 'not-a-number',
tabViewId: 'also-not-a-number',
inLocalStorage: false,
name: 'Invalid Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
1,
);
});
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
syncTableSpy.mockRestore();
});
test('does not call syncTable for already initialized tables', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: true, // Already initialized
queryEditorId: '789',
};
const state = createStateWithQueryEditor({
id: '789',
tabViewId: '789',
inLocalStorage: false,
name: 'Initialized Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
1,
);
});
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
syncTableSpy.mockRestore();
});
test('calls syncTable after query editor is migrated from localStorage', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'temp-editor-id',
};
// Start with editor in localStorage
const localState = createStateWithQueryEditor({
id: 'temp-editor-id',
tabViewId: undefined,
inLocalStorage: true,
name: 'Temp Editor',
});
const { rerender } = render(
<TableElement table={testTable} activeKey={[testTable.id]} />,
{
useRedux: true,
initialState: localState,
},
);
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
const migratedState = createStateWithQueryEditor({
id: 'temp-editor-id',
tabViewId: '999',
inLocalStorage: false,
name: 'Temp Editor',
});
rerender(<TableElement table={testTable} activeKey={[testTable.id]} />);
const { unmount } = render(
<TableElement table={testTable} activeKey={[testTable.id]} />,
{
useRedux: true,
initialState: migratedState,
},
);
await waitFor(() => {
expect(syncTableSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
'999',
);
});
unmount();
syncTableSpy.mockRestore();
});
test('passes numeric queryEditorId validation', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'editor-123',
};
const state = createStateWithQueryEditor({
id: 'editor-123',
tabViewId: '456',
inLocalStorage: false,
name: 'Valid Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(syncTableSpy).toHaveBeenCalled();
const [, , finalQueryEditorId] = syncTableSpy.mock.calls[0];
// Verify it's a valid numeric string
expect(Number.isNaN(Number(finalQueryEditorId))).toBe(false);
expect(typeof finalQueryEditorId).toBe('string');
expect(finalQueryEditorId).toMatch(/^\d+$/);
});
syncTableSpy.mockRestore();
});

View File

@@ -0,0 +1,420 @@
/**
* 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 { useState, useRef, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
import {
ButtonGroup,
Card,
Collapse,
Tooltip,
Flex,
IconTooltip,
Loading,
ModalTrigger,
type CollapseProps,
} from '@superset-ui/core/components';
import { CopyToClipboard } from 'src/components';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
import { debounce } from 'lodash';
import {
removeDataPreview,
removeTables,
addDangerToast,
syncTable,
} from 'src/SqlLab/actions/sqlLab';
import {
tableApiUtil,
useTableExtendedMetadataQuery,
useTableMetadataQuery,
} from 'src/hooks/apiResources';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { ActionType } from 'src/types/Action';
import { Icons } from '@superset-ui/core/components/Icons';
import { Space } from '@superset-ui/core/components/Space';
import ColumnElement, { ColumnKeyTypeType } from '../ColumnElement';
import ShowSQL from '../ShowSQL';
export interface Column {
name: string;
keys?: { type: ColumnKeyTypeType }[];
type: string;
}
export interface TableElementProps extends CollapseProps {
table: Table;
}
const StyledSpan = styled.span`
cursor: pointer;
`;
const Fade = styled.div`
transition: all ${({ theme }) => theme.motionDurationMid};
opacity: ${(props: { hovered: boolean }) => (props.hovered ? 1 : 0)};
`;
const TableElement = ({ table, ...props }: TableElementProps) => {
const { dbId, catalog, schema, name, expanded, id } = table;
const theme = useTheme();
const dispatch = useAppDispatch();
const {
currentData: tableMetadata,
isSuccess: isMetadataSuccess,
isFetching: isMetadataFetching,
isError: hasMetadataError,
} = useTableMetadataQuery(
{
dbId,
catalog,
schema,
table: name,
},
{ skip: !expanded },
);
const {
currentData: tableExtendedMetadata,
isSuccess: isExtraMetadataSuccess,
isLoading: isExtraMetadataLoading,
isError: hasExtendedMetadataError,
} = useTableExtendedMetadataQuery(
{
dbId,
catalog,
schema,
table: name,
},
{ skip: !expanded },
);
const tableData = {
...tableMetadata,
...tableExtendedMetadata,
};
const queryEditors = useSelector<SqlLabRootState, QueryEditor[]>(
state => state.sqlLab.queryEditors,
);
const currentTable = { ...tableData, ...table };
const { queryEditorId } = currentTable;
const queryEditor = queryEditors.find(
qe => qe.id === queryEditorId || qe.tabViewId === queryEditorId,
);
const currentQueryEditorId = queryEditor?.tabViewId || queryEditorId;
useEffect(() => {
if (hasMetadataError || hasExtendedMetadataError) {
dispatch(
addDangerToast(t('An error occurred while fetching table metadata')),
);
}
}, [hasMetadataError, hasExtendedMetadataError, dispatch]);
// TODO: migrate syncTable logic by SIP-93
const syncTableMetadata = useEffectEvent(() => {
const { initialized } = table;
// if not a valid number, wait for backend to assign one
const hasFinalQueryEditorId =
currentQueryEditorId &&
!Number.isNaN(Number(currentQueryEditorId)) &&
currentTable.queryEditorId !== currentQueryEditorId;
if (!initialized && hasFinalQueryEditorId) {
dispatch(syncTable(currentTable, tableData, currentQueryEditorId));
}
});
useEffect(() => {
if (isMetadataSuccess && isExtraMetadataSuccess) {
syncTableMetadata();
}
}, [
isMetadataSuccess,
isExtraMetadataSuccess,
currentQueryEditorId,
syncTableMetadata,
]);
const [sortColumns, setSortColumns] = useState(false);
const [hovered, setHovered] = useState(false);
const tableNameRef = useRef<HTMLInputElement>(null);
const setHover = (hovered: boolean) => {
debounce(() => setHovered(hovered), 100)();
};
const removeTable = () => {
dispatch(removeDataPreview(table));
dispatch(removeTables([table]));
};
const toggleSortColumns = () => {
setSortColumns(prevState => !prevState);
};
const refreshTableMetadata = () => {
dispatch(
tableApiUtil.invalidateTags([{ type: 'TableMetadatas', id: name }]),
);
dispatch(syncTable(table, tableData, table.queryEditorId));
};
const renderWell = () => {
let partitions;
let metadata;
if (tableData.partitions) {
let partitionQuery;
let partitionClipBoard;
if (tableData.partitions.partitionQuery) {
({ partitionQuery } = tableData.partitions);
const tt = t('Copy partition query to clipboard');
partitionClipBoard = (
<CopyToClipboard
text={partitionQuery}
shouldShowText={false}
tooltipText={tt}
copyNode={<Icons.CopyOutlined iconSize="s" />}
/>
);
}
const latest = Object.entries(tableData.partitions?.latest || [])
.map(([key, value]) => `${key}=${value}`)
.join('/');
partitions = (
<div>
<small>
{t('latest partition:')} {latest}
</small>{' '}
{partitionClipBoard}
</div>
);
}
if (tableData.metadata) {
metadata = Object.entries(tableData.metadata).map(([key, value]) => (
<div>
<small>
<strong>{key}:</strong> {value}
</small>
</div>
));
if (!metadata?.length) {
// hide metadata card view
return null;
}
}
if (!partitions) {
// hide partition card view
return null;
}
return (
<Card size="small">
{partitions}
{metadata}
</Card>
);
};
const renderControls = () => {
let keyLink;
const KEYS_FOR_TABLE_TEXT = t('Keys for table');
if (tableData?.indexes?.length) {
keyLink = (
<ModalTrigger
modalTitle={`${KEYS_FOR_TABLE_TEXT} ${name}`}
modalBody={tableData.indexes.map((ix, i) => (
<pre key={i}>{JSON.stringify(ix, null, ' ')}</pre>
))}
triggerNode={
<IconTooltip
className="pull-left"
tooltip={t('View keys & indexes (%s)', tableData.indexes.length)}
>
<Icons.TableOutlined
iconSize="m"
iconColor={theme.colorPrimary}
/>
</IconTooltip>
}
/>
);
}
return (
<Flex style={{ height: 22 }} align="center">
{isMetadataFetching || isExtraMetadataLoading ? (
<Loading position="inline" />
) : (
<Fade
data-test="fade"
hovered={hovered}
onClick={e => e.stopPropagation()}
>
<ButtonGroup>
<Space size="small">
<IconTooltip
className="pull-left pointer"
onClick={refreshTableMetadata}
tooltip={t('Refresh table schema')}
>
<Icons.SyncOutlined
iconSize="m"
iconColor={theme.colorIcon}
/>
</IconTooltip>
{keyLink}
<IconTooltip
onClick={toggleSortColumns}
tooltip={
sortColumns
? t('Original table column order')
: t('Sort columns alphabetically')
}
>
<Icons.SortAscendingOutlined
iconSize="m"
aria-hidden
iconColor={
sortColumns ? theme.colorIcon : theme.colorTextDisabled
}
/>
</IconTooltip>
{tableData.selectStar && (
<CopyToClipboard
copyNode={
<IconTooltip
aria-label={t('Copy')}
tooltip={t('Copy SELECT statement to the clipboard')}
>
<Icons.CopyOutlined
iconSize="m"
aria-hidden
iconColor={theme.colorIcon}
/>
</IconTooltip>
}
text={tableData.selectStar}
shouldShowText={false}
/>
)}
{tableData.view && (
<ShowSQL
sql={tableData.view}
tooltipText={t('Show CREATE VIEW statement')}
title={t('CREATE VIEW statement')}
/>
)}
<IconTooltip
className=" table-remove pull-left pointer"
onClick={removeTable}
tooltip={t('Remove table preview')}
>
<Icons.CloseOutlined
iconSize="m"
aria-hidden
iconColor={theme.colorIcon}
/>
</IconTooltip>
</Space>
</ButtonGroup>
</Fade>
)}
</Flex>
);
};
const renderHeader = () => {
const element: HTMLInputElement | null = tableNameRef.current;
let trigger = [] as ActionType[];
if (element && element.offsetWidth < element.scrollWidth) {
trigger = ['hover'];
}
return (
<div
data-test="table-element-header-container"
className="clearfix header-container"
>
<Tooltip
id="copy-to-clipboard-tooltip"
style={{ cursor: 'pointer' }}
title={name}
trigger={trigger}
>
<StyledSpan
data-test="collapse"
ref={tableNameRef}
className="table-name"
>
<strong>{name}</strong>
</StyledSpan>
</Tooltip>
</div>
);
};
const renderBody = () => {
let cols;
if (tableData.columns) {
cols = tableData.columns.slice();
if (sortColumns) {
cols.sort((a: Column, b: Column) => {
const colA = a.name.toUpperCase();
const colB = b.name.toUpperCase();
return colA < colB ? -1 : colA > colB ? 1 : 0;
});
}
}
const metadata = (
<div data-test="table-element" css={{ paddingTop: 6 }}>
{renderWell()}
<div>
{cols?.map(col => (
<ColumnElement column={col} key={col.name} />
))}
</div>
</div>
);
return metadata;
};
return (
<Collapse
activeKey={props.activeKey}
expandIconPosition="end"
onChange={props.onChange}
ghost
items={[
{
key: id,
label: renderHeader(),
children: renderBody(),
extra: renderControls(),
onMouseEnter: () => setHover(true),
onMouseLeave: () => setHover(false),
},
]}
/>
);
};
export default TableElement;

View File

@@ -98,10 +98,7 @@ class CopyToClip extends Component<CopyToClipboardProps> {
trigger={['hover']}
arrow={{ pointAtCenter: true }}
>
{/* Wrap in a span so antd Tooltip has a real DOM ref target;
avoids findDOMNode fallback when copyNode is a function
component without forwardRef. */}
<span>{this.getDecoratedCopyNode()}</span>
{this.getDecoratedCopyNode()}
</Tooltip>
) : (
this.getDecoratedCopyNode()

View File

@@ -17,25 +17,21 @@
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import { forwardRef, PropsWithoutRef, Ref, RefAttributes } from 'react';
import { PropsWithoutRef, RefAttributes } from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { isUrlExternal, parseUrl } from 'src/utils/urlUtils';
type GenericLinkProps<S> = PropsWithoutRef<LinkProps<S>> &
RefAttributes<HTMLAnchorElement>;
const GenericLinkInner = <S,>(
{ to, component, replace, innerRef, children, ...rest }: GenericLinkProps<S>,
ref: Ref<HTMLAnchorElement>,
) => {
export const GenericLink = <S,>({
to,
component,
replace,
innerRef,
children,
...rest
}: PropsWithoutRef<LinkProps<S>> & RefAttributes<HTMLAnchorElement>) => {
if (typeof to === 'string' && isUrlExternal(to)) {
return (
<a
ref={ref}
data-test="external-link"
href={sanitizeUrl(parseUrl(to))}
{...rest}
>
<a data-test="external-link" href={sanitizeUrl(parseUrl(to))} {...rest}>
{children}
</a>
);
@@ -46,14 +42,10 @@ const GenericLinkInner = <S,>(
to={to}
component={component}
replace={replace}
innerRef={innerRef ?? ref}
innerRef={innerRef}
{...rest}
>
{children}
</Link>
);
};
export const GenericLink = forwardRef(GenericLinkInner) as <S>(
props: GenericLinkProps<S> & { ref?: Ref<HTMLAnchorElement> },
) => ReturnType<typeof GenericLinkInner>;

View File

@@ -295,7 +295,6 @@ export interface ListViewProps<T extends object = any> {
name: ReactNode;
onSelect: (rows: any[]) => any;
type?: 'primary' | 'secondary' | 'danger';
hidden?: (rows: any[]) => boolean;
}>;
bulkSelectEnabled?: boolean;
disableBulkSelect?: () => void;
@@ -510,16 +509,7 @@ export function ListView<T extends object = any>({
{t('Deselect all')}
</span>
<div className="divider" />
{bulkActions
.filter(
action =>
!action.hidden?.(
selectedFlatRows.map(
(r: any) => r.original,
),
),
)
.map(action => (
{bulkActions.map(action => (
<Button
data-test="bulk-select-action"
data-test-action-key={action.key}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react';
import { PureComponent, ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
@@ -90,61 +90,165 @@ interface VisibilityEventData {
ts: number;
}
function unload(event: BeforeUnloadEvent): string {
const message = t('You have unsaved changes.');
// Set returnValue on the actual event object to trigger the browser prompt
event.returnValue = message;
return message; // Gecko + Webkit, Safari, Chrome etc.
}
class Dashboard extends PureComponent<DashboardProps> {
static contextType = PluginContext;
function onBeforeUnload(hasChanged: boolean): void {
if (hasChanged) {
window.addEventListener('beforeunload', unload);
} else {
window.removeEventListener('beforeunload', unload);
// Use type assertion when accessing context instead of declare field
// to avoid babel transformation issues in Jest
static defaultProps = {
timeout: 60,
userId: '',
};
appliedFilters: ActiveFilters;
appliedOwnDataCharts: JsonObject;
visibilityEventData: VisibilityEventData;
static onBeforeUnload(hasChanged: boolean): void {
if (hasChanged) {
window.addEventListener('beforeunload', Dashboard.unload);
} else {
window.removeEventListener('beforeunload', Dashboard.unload);
}
}
}
function Dashboard({
actions,
dashboardId,
editMode,
isPublished,
hasUnsavedChanges,
slices,
activeFilters,
chartConfiguration,
datasources,
ownDataCharts,
layout,
impressionId,
timeout = 60,
userId = '',
children,
}: DashboardProps): JSX.Element {
const context = useContext(PluginContext) as PluginContextType;
static unload(): string {
const message = t('You have unsaved changes.');
// Gecko + IE: returnValue is typed as boolean but historically accepts string
(window.event as BeforeUnloadEvent).returnValue = message;
return message; // Gecko + Webkit, Safari, Chrome etc.
}
// Use refs to track mutable values that persist across renders
const appliedFiltersRef = useRef<ActiveFilters>(activeFilters ?? {});
const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
const visibilityEventDataRef = useRef<VisibilityEventData>({
start_offset: 0,
ts: 0,
});
const prevLayoutRef = useRef<DashboardLayout>(layout);
const prevDashboardIdRef = useRef<number>(dashboardId);
constructor(props: DashboardProps) {
super(props);
this.appliedFilters = props.activeFilters ?? {};
this.appliedOwnDataCharts = props.ownDataCharts ?? {};
this.visibilityEventData = { start_offset: 0, ts: 0 };
this.onVisibilityChange = this.onVisibilityChange.bind(this);
}
const refreshCharts = useCallback(
(ids: (string | number)[]): void => {
ids.forEach(id => {
actions.triggerQuery(true, id);
componentDidMount(): void {
const bootstrapData = getBootstrapData();
const { editMode, isPublished, layout } = this.props;
const eventData: Record<string, unknown> = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
window.addEventListener('visibilitychange', this.onVisibilityChange);
this.applyCharts();
}
componentDidUpdate(prevProps: DashboardProps): void {
this.applyCharts();
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
const nextChartIds = getChartIdsFromLayout(this.props.layout);
if (prevProps.dashboardId !== this.props.dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
this.props.actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(this.props.layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
this.props.actions.removeSliceFromDashboard(removedChartId),
);
}
}
applyCharts(): void {
const {
activeFilters,
ownDataCharts,
chartConfiguration,
hasUnsavedChanges,
editMode,
} = this.props;
const { appliedFilters, appliedOwnDataCharts } = this;
if (!chartConfiguration) {
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
// for correct comparing of filters to avoid unnecessary requests
return;
}
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFilters, activeFilters, {
ignoreUndefined: true,
}))
) {
this.applyFilters();
}
if (hasUnsavedChanges) {
Dashboard.onBeforeUnload(true);
} else {
Dashboard.onBeforeUnload(false);
}
}
componentWillUnmount(): void {
window.removeEventListener('visibilitychange', this.onVisibilityChange);
this.props.actions.clearDataMaskState();
this.props.actions.clearAllChartStates();
}
onVisibilityChange(): void {
if (document.visibilityState === 'hidden') {
// from visible to hidden
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = this.visibilityEventData.start_offset;
this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...this.visibilityEventData,
duration: Logger.getTimestamp() - logStart,
});
},
[actions],
);
}
}
const applyFilters = useCallback((): void => {
const appliedFilters = appliedFiltersRef.current;
applyFilters(): void {
const { appliedFilters } = this;
const { activeFilters, ownDataCharts, slices } = this.props;
// refresh charts if a filter was removed, added, or changed
@@ -154,7 +258,7 @@ function Dashboard({
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
ownDataCharts,
appliedOwnDataChartsRef.current,
this.appliedOwnDataCharts,
);
[...allKeys].forEach(filterKey => {
@@ -217,157 +321,24 @@ function Dashboard({
});
// remove dup in affectedChartIds
refreshCharts([...new Set(affectedChartIds)]);
appliedFiltersRef.current = activeFilters;
appliedOwnDataChartsRef.current = ownDataCharts;
}, [activeFilters, ownDataCharts, slices, refreshCharts]);
const applyCharts = useCallback((): void => {
if (!chartConfiguration) {
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
// for correct comparing of filters to avoid unnecessary requests
return;
}
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFiltersRef.current, activeFilters, {
ignoreUndefined: true,
}))
) {
applyFilters();
}
if (hasUnsavedChanges) {
onBeforeUnload(true);
} else {
onBeforeUnload(false);
}
}, [
chartConfiguration,
editMode,
ownDataCharts,
activeFilters,
hasUnsavedChanges,
applyFilters,
]);
const onVisibilityChange = useCallback((): void => {
if (document.visibilityState === 'hidden') {
// from visible to hidden
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = visibilityEventDataRef.current.start_offset;
actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...visibilityEventDataRef.current,
duration: Logger.getTimestamp() - logStart,
});
}
}, [actions]);
// Refs that always point at the latest closures so the mount-only effect's
// listeners/cleanup never invoke a stale `actions` closure when `actions`
// identity changes.
const onVisibilityChangeRef = useRef(onVisibilityChange);
const actionsRef = useRef(actions);
useEffect(() => {
onVisibilityChangeRef.current = onVisibilityChange;
actionsRef.current = actions;
});
// componentDidMount equivalent
useEffect(() => {
const bootstrapData = getBootstrapData();
const eventData: Record<string, unknown> = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
const handleVisibilityChange = () => onVisibilityChangeRef.current();
document.addEventListener('visibilitychange', handleVisibilityChange);
// componentWillUnmount equivalent
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
onBeforeUnload(false); // Remove beforeunload listener on unmount
actionsRef.current.clearDataMaskState();
actionsRef.current.clearAllChartStates();
};
// Only run on mount/unmount - listeners/cleanup go through refs to avoid
// capturing stale closures.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts)
useEffect(() => {
applyCharts();
}, [applyCharts]);
// componentDidUpdate equivalent for layout changes
useEffect(() => {
const prevLayout = prevLayoutRef.current;
const prevDashboardId = prevDashboardIdRef.current;
// Update refs for next comparison
prevLayoutRef.current = layout;
prevDashboardIdRef.current = dashboardId;
const currentChartIds = getChartIdsFromLayout(prevLayout);
const nextChartIds = getChartIdsFromLayout(layout);
if (prevDashboardId !== dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
actions.removeSliceFromDashboard(removedChartId),
);
}
}, [layout, dashboardId, actions]);
if (context.loading) {
return <Loading />;
this.refreshCharts([...new Set(affectedChartIds)]);
this.appliedFilters = activeFilters;
this.appliedOwnDataCharts = ownDataCharts;
}
refreshCharts(ids: (string | number)[]): void {
ids.forEach(id => {
this.props.actions.triggerQuery(true, id);
});
}
render(): ReactNode {
const context = this.context as PluginContextType;
if (context.loading) {
return <Loading />;
}
return this.props.children;
}
return <>{children}</>;
}
export default Dashboard;

View File

@@ -31,10 +31,9 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
};
export const shouldFocusTabs = (
event: { target: HTMLElement },
container: Pick<Node, 'contains'> | null,
_menuRef: HTMLDivElement | null,
): boolean =>
event: { target: { className: string } },
container: { contains: (arg0: any) => any },
) =>
// don't focus the tabs when we click on a tab
event.target.className === 'ant-tabs-nav-wrap' ||
(container?.contains(event.target) ?? false);
container.contains(event.target);

View File

@@ -16,11 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Fragment, useCallback, useRef, useState } from 'react';
import { PureComponent, Fragment } from 'react';
import { withTheme } from '@emotion/react';
import classNames from 'classnames';
import { addAlpha } from '@superset-ui/core';
import { css, styled, type SupersetTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { EmptyState } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { navigateTo } from 'src/utils/navigationUtils';
@@ -47,6 +48,11 @@ export interface DashboardGridProps {
setEditMode?: (editMode: boolean) => void;
width: number;
dashboardId?: number;
theme: SupersetTheme;
}
interface DashboardGridState {
isResizing: boolean;
}
interface DropProps {
@@ -125,235 +131,261 @@ const GridColumnGuide = styled.div`
`};
`;
function DashboardGrid({
depth,
editMode,
canEdit,
gridComponent,
handleComponentDrop,
isComponentVisible,
resizeComponent,
setDirectPathToChild,
setEditMode,
width,
dashboardId,
}: DashboardGridProps) {
const theme = useTheme();
const [isResizing, setIsResizing] = useState(false);
const gridRef = useRef<HTMLDivElement | null>(null);
class DashboardGrid extends PureComponent<
DashboardGridProps,
DashboardGridState
> {
grid: HTMLDivElement | null;
const setGridRef = useCallback((ref: HTMLDivElement | null): void => {
gridRef.current = ref;
}, []);
constructor(props: DashboardGridProps) {
super(props);
this.state = {
isResizing: false,
};
this.grid = null;
this.handleResizeStart = this.handleResizeStart.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleResizeStop = this.handleResizeStop.bind(this);
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
this.setGridRef = this.setGridRef.bind(this);
this.handleChangeTab = this.handleChangeTab.bind(this);
}
const handleResizeStart = useCallback((): void => {
setIsResizing(true);
}, []);
getRowGuidePosition(resizeRef: HTMLElement | null): number | null {
if (resizeRef && this.grid) {
return (
resizeRef.getBoundingClientRect().bottom -
this.grid.getBoundingClientRect().top -
2
);
}
return null;
}
const handleResize = useCallback(
(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
_delta: { width: number; height: number },
): void => {
// no-op: resize position tracking not implemented
},
[],
);
setGridRef(ref: HTMLDivElement | null): void {
this.grid = ref;
}
const handleResizeStop = useCallback(
(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
): void => {
resizeComponent({
id,
width: delta.width,
height: delta.height,
handleResizeStart(): void {
this.setState(() => ({
isResizing: true,
}));
}
handleResize(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
_delta: { width: number; height: number },
): void {
// no-op: resize position is tracked via getRowGuidePosition
}
handleResizeStop(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
): void {
this.props.resizeComponent({
id,
width: delta.width,
height: delta.height,
});
this.setState(() => ({
isResizing: false,
}));
}
handleTopDropTargetDrop(dropResult: DropResult): void {
if (dropResult?.destination) {
this.props.handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
});
}
}
setIsResizing(false);
},
[resizeComponent],
);
handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void {
this.props.setDirectPathToChild(pathToTabIndex);
}
const handleTopDropTargetDrop = useCallback(
(dropResult: DropResult): void => {
if (dropResult?.destination) {
handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
});
}
},
[handleComponentDrop],
);
render() {
const {
gridComponent,
handleComponentDrop,
depth,
width,
isComponentVisible,
editMode,
canEdit,
setEditMode,
dashboardId,
theme,
} = this.props;
const columnPlusGutterWidth =
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const handleChangeTab = useCallback(
({ pathToTabIndex }: { pathToTabIndex: string[] }): void => {
setDirectPathToChild(pathToTabIndex);
},
[setDirectPathToChild],
);
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
const { isResizing } = this.state;
const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
const dashboardEmptyState = editMode && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
const topLevelTabEmptyState = editMode ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={
canEdit && t('You can add the components in the edit mode.')
}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
const dashboardEmptyState = editMode && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const topLevelTabEmptyState = editMode ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={canEdit && t('You can add the components in the edit mode.')}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full': gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={handleResizeStart}
onResize={handleResize}
onResizeStop={handleResizeStop}
onChangeTab={handleChangeTab}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={this.setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={this.handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full':
gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={this.handleResizeStart}
onResize={this.handleResize}
onResizeStop={this.handleResizeStop}
onChangeTab={this.handleChangeTab}
/>
))}
</GridContent>
</div>
</>
);
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
))}
</GridContent>
</div>
</>
);
}
}
export default DashboardGrid;
export default withTheme(DashboardGrid);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from 'react';
import { Component } from 'react';
import { t } from '@apache-superset/core/translation';
import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
import { HeaderProps, HeaderDropdownProps } from '../Header/types';
@@ -43,64 +43,70 @@ const publishedTooltip = t(
'This dashboard is published. Click to make it a draft.',
);
export default function PublishedStatus({
dashboardId,
userCanEdit,
userCanSave,
isPublished,
savePublished,
}: DashboardPublishedStatusType) {
const togglePublished = useCallback(() => {
savePublished(dashboardId, !isPublished);
}, [dashboardId, isPublished, savePublished]);
export default class PublishedStatus extends Component<DashboardPublishedStatusType> {
constructor(props: DashboardPublishedStatusType) {
super(props);
this.togglePublished = this.togglePublished.bind(this);
}
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
togglePublished() {
this.props.savePublished(this.props.dashboardId, !this.props.isPublished);
}
render() {
const { isPublished, userCanEdit, userCanSave } = this.props;
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
/>
</div>
</Tooltip>
);
}
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftButtonTooltip}
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={togglePublished}
onClick={this.togglePublished}
/>
</div>
</Tooltip>
);
}
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
</div>
</Tooltip>
);
// Don't show anything if one doesn't own the dashboard and it is published
return null;
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
}

View File

@@ -17,13 +17,13 @@
* under the License.
*/
/* eslint-env browser */
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Component } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
// @ts-expect-error
import { createFilter } from 'react-search-input';
import { t } from '@apache-superset/core/translation';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { styled, css } from '@apache-superset/core/theme';
import {
Button,
Checkbox,
@@ -49,6 +49,7 @@ import {
import { debounce, pickBy } from 'lodash';
import { Dispatch } from 'redux';
import { Slice } from 'src/dashboard/types';
import { withTheme, Theme } from '@emotion/react';
import { navigateTo } from 'src/utils/navigationUtils';
import type { ConnectDragSource } from 'react-dnd';
import AddSliceCard from './AddSliceCard';
@@ -57,6 +58,7 @@ import { DragDroppable } from './dnd/DragDroppable';
import { datasetLabelLower } from 'src/features/semanticLayers/label';
export type SliceAdderProps = {
theme: Theme;
fetchSlices: (
userId?: number,
filter_value?: string,
@@ -75,6 +77,14 @@ export type SliceAdderProps = {
dashboardId: number;
};
type SliceAdderState = {
filteredSlices: Slice[];
searchTerm: string;
sortBy: keyof Slice;
selectedSliceIdsSet: Set<number>;
showOnlyMyCharts: boolean;
};
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
const KEYS_TO_SORT = {
slice_name: t('name'),
@@ -164,308 +174,295 @@ function getFilteredSortedSlices(
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
.sort(sortByComparator(sortBy));
}
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
private slicesRequest?: AbortController | Promise<void>;
function SliceAdder({
fetchSlices,
updateSlices,
isLoading,
slices,
errorMessage = '',
userId,
selectedSliceIds = [],
editMode = false,
dashboardId,
}: SliceAdderProps) {
const theme = useTheme();
const slicesRequestRef = useRef<AbortController | Promise<void>>();
static defaultProps = {
selectedSliceIds: [],
editMode: false,
errorMessage: '',
};
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
() => new Set(selectedSliceIds),
);
// Refs to track latest values for cleanup effect
const latestSlicesRef = useRef(slices);
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
);
// Keep refs updated with latest values
useEffect(() => {
latestSlicesRef.current = slices;
}, [slices]);
useEffect(() => {
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
}, [selectedSliceIdsSet]);
const filteredSlices = useMemo(
() =>
getFilteredSortedSlices(
slices,
searchTerm,
sortBy,
showOnlyMyCharts,
userId,
constructor(props: SliceAdderProps) {
super(props);
this.state = {
filteredSlices: [],
searchTerm: '',
sortBy: DEFAULT_SORT_KEY,
selectedSliceIdsSet: new Set(props.selectedSliceIds),
showOnlyMyCharts: getItem(
LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
true,
),
[slices, searchTerm, sortBy, showOnlyMyCharts, userId],
);
};
this.rowRenderer = this.rowRenderer.bind(this);
this.searchUpdated = this.searchUpdated.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.userIdForFetch = this.userIdForFetch.bind(this);
this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
}
const userIdForFetch = useCallback(
() => (showOnlyMyCharts ? userId : undefined),
[showOnlyMyCharts, userId],
);
userIdForFetch() {
return this.state.showOnlyMyCharts ? this.props.userId : undefined;
}
// Refs so the debounced search reads the latest sortBy/userIdForFetch at
// fire time without recreating the debounce (which would drop a pending,
// armed-but-not-yet-fired search when sortBy/showOnlyMyCharts change).
const sortByRef = useRef(sortBy);
const userIdForFetchRef = useRef(userIdForFetch);
useEffect(() => {
sortByRef.current = sortBy;
}, [sortBy]);
useEffect(() => {
userIdForFetchRef.current = userIdForFetch;
}, [userIdForFetch]);
componentDidMount() {
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
'',
this.state.sortBy,
);
}
// componentDidMount
useEffect(() => {
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update selectedSliceIdsSet when selectedSliceIds prop changes
useEffect(() => {
setSelectedSliceIdsSet(new Set(selectedSliceIds));
}, [selectedSliceIds]);
// componentWillUnmount
useEffect(
() => () => {
// Clears the redux store keeping only selected items
// Use refs to get latest values on unmount
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
componentDidUpdate(prevProps: SliceAdderProps) {
const nextState: SliceAdderState = {} as SliceAdderState;
if (this.props.lastUpdated !== prevProps.lastUpdated) {
nextState.filteredSlices = getFilteredSortedSlices(
this.props.slices,
this.state.searchTerm,
this.state.sortBy,
this.state.showOnlyMyCharts,
this.props.userId,
);
}
updateSlices(selectedSlices);
if (slicesRequestRef.current instanceof AbortController) {
slicesRequestRef.current.abort();
}
},
[updateSlices],
);
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
}
const searchUpdated = useCallback((term: string) => {
setSearchTerm(term);
}, []);
if (Object.keys(nextState).length) {
this.setState(nextState);
}
}
const fetchSlicesRef = useRef(fetchSlices);
useEffect(() => {
fetchSlicesRef.current = fetchSlices;
}, [fetchSlices]);
componentWillUnmount() {
// Clears the redux store keeping only selected items
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
this.state.selectedSliceIdsSet.has(value.slice_id),
);
// Create the debounce once (stable identity) so a pending search isn't
// dropped when sortBy/userIdForFetch change mid-typing. The debounced
// function reads the latest values from refs at fire time.
const handleChange = useMemo(
() =>
debounce((value: string) => {
searchUpdated(value);
slicesRequestRef.current = fetchSlicesRef.current(
userIdForFetchRef.current(),
value,
sortByRef.current,
);
}, 300),
[searchUpdated],
);
this.props.updateSlices(selectedSlices);
if (this.slicesRequest instanceof AbortController) {
this.slicesRequest.abort();
}
}
useEffect(
() => () => {
handleChange.cancel();
},
[handleChange],
);
handleChange = debounce(value => {
this.searchUpdated(value);
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
value,
this.state.sortBy,
);
}, 300);
const handleSelect = useCallback(
(newSortBy: keyof Slice) => {
setSortBy(newSortBy);
slicesRequestRef.current = fetchSlices(
userIdForFetch(),
searchUpdated(searchTerm: string) {
this.setState(prevState => ({
searchTerm,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
searchTerm,
newSortBy,
prevState.sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
}
handleSelect(sortBy: keyof Slice) {
this.setState(prevState => ({
sortBy,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
this.state.searchTerm,
sortBy,
);
}
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
const { filteredSlices, selectedSliceIdsSet } = this.state;
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={this.props.editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
}
onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
if (!showOnlyMyCharts) {
this.slicesRequest = this.props.fetchSlices(
undefined,
this.state.searchTerm,
this.state.sortBy,
);
},
[fetchSlices, searchTerm, userIdForFetch],
);
}
this.setState(prevState => ({
showOnlyMyCharts,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
prevState.sortBy,
showOnlyMyCharts,
this.props.userId,
),
}));
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
};
const onShowOnlyMyCharts = useCallback(
(checked: boolean) => {
if (!checked) {
slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy);
}
setShowOnlyMyCharts(checked);
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked);
},
[fetchSlices, searchTerm, sortBy],
);
const rowRenderer = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
},
[filteredSlices, selectedSliceIdsSet, editMode],
);
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
}
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
}
className="search-input"
onChange={ev => handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={sortBy}
onChange={handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
render() {
const { theme } = this.props;
return (
<div
css={themeObj => css`
css={css`
height: 100%;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${themeObj.sizeUnit}px;
padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px
${themeObj.sizeUnit * 3}px;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
}
`}
>
<Checkbox
onChange={e => onShowOnlyMyCharts(e.target.checked)}
checked={showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
</div>
{isLoading && <Loading />}
{!isLoading && filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => filteredSlices[index].slice_id}
>
{rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{errorMessage && (
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
this.state.showOnlyMyCharts
? t('Filter your charts')
: t('Filter charts')
}
className="search-input"
onChange={ev => this.handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={this.state.sortBy}
onChange={this.handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<div
css={css`
padding: 16px;
css={theme => css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${theme.sizeUnit}px;
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
${theme.sizeUnit * 3}px;
`}
>
{errorMessage}
<Checkbox
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
checked={this.state.showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
</div>
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={filteredSlices} />
</div>
);
{this.props.isLoading && <Loading />}
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={this.state.filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => this.state.filteredSlices[index].slice_id}
>
{this.rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{this.props.errorMessage && (
<div
css={css`
padding: 16px;
`}
>
{this.props.errorMessage}
</div>
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={this.state.filteredSlices} />
</div>
);
}
}
export default SliceAdder;
export default withTheme(SliceAdder);

View File

@@ -43,40 +43,6 @@ test('triggers onRedo', () => {
expect(onRedo).toHaveBeenCalledTimes(1);
});
test('triggers onRedo with Ctrl+Shift+Z', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
fireEvent.keyDown(document.body, {
key: 'z',
keyCode: 90,
ctrlKey: true,
shiftKey: true,
});
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('triggers onUndo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'z' glyph (e.g. non-Latin layout), but code is KeyZ
fireEvent.keyDown(document.body, { key: 'я', code: 'KeyZ', ctrlKey: true });
expect(onUndo).toHaveBeenCalledTimes(1);
expect(onRedo).not.toHaveBeenCalled();
});
test('triggers onRedo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'y' glyph, but code is KeyY
fireEvent.keyDown(document.body, { key: 'н', code: 'KeyY', ctrlKey: true });
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('does not trigger when it is another key', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect } from 'react';
import { PureComponent } from 'react';
import { HeaderProps } from '../Header/types';
type UndoRedoKeyListenersProps = {
@@ -24,43 +24,43 @@ type UndoRedoKeyListenersProps = {
onRedo: HeaderProps['onRedo'];
};
function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) {
const handleKeydown = useCallback(
(event: KeyboardEvent) => {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const key = event.key.toLowerCase();
// Fall back to event.code (the physical key) so undo/redo still work on
// non-Latin keyboard layouts where event.key is a different glyph.
const isZ = key === 'z' || event.code === 'KeyZ';
const isY = key === 'y' || event.code === 'KeyY';
const isUndo = isZ && !event.shiftKey;
const isRedo = isY || (isZ && event.shiftKey);
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
class UndoRedoKeyListeners extends PureComponent<UndoRedoKeyListenersProps> {
constructor(props: UndoRedoKeyListenersProps) {
super(props);
this.handleKeydown = this.handleKeydown.bind(this);
}
if (!isEditingMarkdown && !isEditingTitle && (isUndo || isRedo)) {
event.preventDefault();
const func = isUndo ? onUndo : onRedo;
func();
}
componentDidMount() {
document.addEventListener('keydown', this.handleKeydown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.handleKeydown);
}
handleKeydown(event: KeyboardEvent) {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const isZChar = event.key === 'z' || event.keyCode === 90;
const isYChar = event.key === 'y' || event.keyCode === 89;
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) {
event.preventDefault();
const func = isZChar ? this.props.onUndo : this.props.onRedo;
func();
}
},
[onUndo, onRedo],
);
}
}
useEffect(() => {
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleKeydown]);
return null;
render() {
return null;
}
}
export default UndoRedoKeyListeners;

View File

@@ -33,6 +33,7 @@ import {
} from 'react-dnd';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import { dragConfig, dropConfig } from './dragDroppableConfig';
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
import { DROP_FORBIDDEN } from '../../util/getDropPosition';
@@ -121,22 +122,15 @@ const DragDroppableStyles = styled.div`
}
`};
`;
/**
* Note: This component remains a class component because it is tightly integrated
* with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs
* access component instance properties directly (mounted, ref, props, setState)
* in the hover/drop callbacks defined in dragDroppableConfig.ts.
*
* Converting to a function component would require migrating to react-dnd's
* hooks API (useDrag/useDrop), which would be a more extensive refactor.
*/
// export unwrapped component for testing
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties
export class UnwrappedDragDroppable extends PureComponent<
DragDroppableAllProps,
DragDroppableState
> {
mounted: boolean;
ref: HTMLDivElement | null;
static defaultProps = {
className: null,
style: null,
@@ -158,10 +152,6 @@ export class UnwrappedDragDroppable extends PureComponent<
dragPreviewRef() {},
};
mounted: boolean;
ref: HTMLDivElement | null;
constructor(props: DragDroppableAllProps) {
super(props);
this.state = {
@@ -293,6 +283,7 @@ export class UnwrappedDragDroppable extends PureComponent<
// react-dnd's DragSource/DropTarget HOC types don't play well with
// class components using spread config tuples, so we use type assertions here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DragDroppableAsAny =
UnwrappedDragDroppable as unknown as ReactComponentType<
Record<string, unknown>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, useCallback } from 'react';
import { createRef, PureComponent } from 'react';
import { styled } from '@apache-superset/core/theme';
import {
ModalTrigger,
@@ -33,29 +33,39 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({
paddingBottom: sizeUnit * 3,
}));
export default function FilterScopeModal({
triggerNode,
}: FilterScopeModalProps) {
const modalRef = useRef<ModalTriggerRef['current']>(null);
export default class FilterScopeModal extends PureComponent<
FilterScopeModalProps,
{}
> {
modal: ModalTriggerRef;
const handleCloseModal = useCallback((): void => {
modalRef.current?.close?.();
}, []);
constructor(props: FilterScopeModalProps) {
super(props);
const filterScopeProps = {
onCloseModal: handleCloseModal,
};
this.modal = createRef() as ModalTriggerRef;
this.handleCloseModal = this.handleCloseModal.bind(this);
}
return (
<ModalTrigger
ref={modalRef}
triggerNode={triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
handleCloseModal(): void {
this?.modal?.current?.close?.();
}
render() {
const filterScopeProps = {
onCloseModal: this.handleCloseModal,
};
return (
<ModalTrigger
ref={this.modal}
triggerNode={this.props.triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}
}

View File

@@ -1,265 +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 {
cleanup,
render,
screen,
userEvent,
} from 'spec/helpers/testing-library';
import FilterScopeSelector from './FilterScopeSelector';
import type { DashboardLayout } from 'src/dashboard/types';
// --- Mock child components ---
jest.mock('./FilterFieldTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-field-tree">
FilterFieldTree (checked={String(props.checked)})
</div>
),
}));
jest.mock('./FilterScopeTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-scope-tree">
FilterScopeTree (checked={String(props.checked)})
</div>
),
}));
// --- Mock utility functions ---
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ALL_FILTERS_ROOT',
label: 'All filters',
children: [
{
value: 1,
label: 'Filter A',
children: [
{ value: '1_column_b', label: 'Filter B' },
{ value: '1_column_c', label: 'Filter C' },
],
},
],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ROOT_ID',
label: 'All charts',
children: [{ value: 2, label: 'Chart A' }],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
__esModule: true,
default: jest.fn(() => ['ROOT_ID']),
}));
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => '1_column_b'),
}));
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => 1),
}));
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
__esModule: true,
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
}));
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
}));
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
const mockDashboardFilters = {
1: {
chartId: 1,
componentId: 'component-1',
filterName: 'Filter A',
datasourceId: 'ds-1',
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
isDateFilter: false,
isInstantFilter: false,
columns: { column_b: undefined, column_c: undefined },
labels: { column_b: 'Filter B', column_c: 'Filter C' },
scopes: {
column_b: { immune: [], scope: ['ROOT_ID'] },
column_c: { immune: [], scope: ['ROOT_ID'] },
},
},
};
const mockLayout: DashboardLayout = {
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
GRID: {
children: ['CHART_1', 'CHART_2'],
id: 'GRID',
type: 'GRID',
parents: ['ROOT_ID'],
},
CHART_1: {
meta: { chartId: 1, sliceName: 'Chart 1' },
children: [],
id: 'CHART_1',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
CHART_2: {
meta: { chartId: 2, sliceName: 'Chart 2' },
children: [],
id: 'CHART_2',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
} as unknown as DashboardLayout;
const defaultProps = {
dashboardFilters: mockDashboardFilters,
layout: mockLayout,
updateDashboardFiltersScope: jest.fn(),
setUnsavedChanges: jest.fn(),
onCloseModal: jest.fn(),
};
test('renders the header, filter field panel, and scope panel', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
});
test('renders the search input with correct placeholder', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute('type', 'text');
});
test('renders Close and Save buttons when filters exist', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
test('renders only Close button and a warning when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(
screen.getByText('There are no filters in this dashboard.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Save' }),
).not.toBeInTheDocument();
});
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
});
test('calls onCloseModal when Close button is clicked', () => {
const onCloseModal = jest.fn();
render(
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
const updateDashboardFiltersScope = jest.fn();
const setUnsavedChanges = jest.fn();
const onCloseModal = jest.fn();
render(
<FilterScopeSelector
{...defaultProps}
updateDashboardFiltersScope={updateDashboardFiltersScope}
setUnsavedChanges={setUnsavedChanges}
onCloseModal={onCloseModal}
/>,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
// The active filter label should appear (column_b maps to "Filter B")
expect(screen.getByText('Filter B')).toBeInTheDocument();
});
test('updates search text when typing in the search input', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
userEvent.type(searchInput, 'Chart');
expect(searchInput).toHaveValue('Chart');
});

View File

@@ -16,17 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
useState,
useCallback,
useMemo,
ChangeEvent,
type ReactElement,
} from 'react';
import { PureComponent, ChangeEvent, type ReactElement } from 'react';
import cx from 'classnames';
import { Button, Input } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
@@ -95,6 +90,30 @@ export interface FilterScopeSelectorProps {
onCloseModal: () => void;
}
interface FilterScopeSelectorStateWithSelector {
showSelector: true;
activeFilterField: string | null;
searchText: string;
filterScopeMap: FilterScopeMap;
filterFieldNodes: FilterFieldNode[];
checkedFilterFields: string[];
expandedFilterIds: (string | number)[];
}
interface FilterScopeSelectorStateWithoutSelector {
showSelector: false;
activeFilterField?: undefined;
searchText?: undefined;
filterScopeMap?: undefined;
filterFieldNodes?: undefined;
checkedFilterFields?: undefined;
expandedFilterIds?: undefined;
}
type FilterScopeSelectorState =
| FilterScopeSelectorStateWithSelector
| FilterScopeSelectorStateWithoutSelector;
const ScopeContainer = styled.div`
${({ theme }) => css`
display: flex;
@@ -370,358 +389,271 @@ const ActionsContainer = styled.div`
`}
`;
function initializeState(
dashboardFilters: Record<number, DashboardFilter>,
layout: DashboardLayout,
) {
if (Object.keys(dashboardFilters).length === 0) {
return {
showSelector: false as const,
allFilterFields: [] as string[],
defaultFilterKey: '',
};
}
export default class FilterScopeSelector extends PureComponent<
FilterScopeSelectorProps,
FilterScopeSelectorState
> {
allfilterFields: string[];
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
const allFilterFields: string[] = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
allFilterFields.push(String(child.value));
});
});
const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? '');
defaultFilterKey: string;
// build FilterScopeTree object for each filterKey
const filterScopeMap: FilterScopeMap = Object.values(
dashboardFilters,
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
(mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: String(filterId),
column: columnName,
constructor(props: FilterScopeSelectorProps) {
super(props);
this.allfilterFields = [];
this.defaultFilterKey = '';
const { dashboardFilters, layout } = props;
if (Object.keys(dashboardFilters).length > 0) {
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
this.allfilterFields = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
this.allfilterFields.push(String(child.value));
});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter((id: number) => id !== filterId);
return {
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
},
{},
);
return {
...map,
...filterScopeByChartId,
};
}, {});
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey);
const checkedFilterFields: string[] = [];
const activeFilterField = defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
return {
showSelector: true as const,
allFilterFields,
defaultFilterKey,
initialState: {
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
},
};
}
export default function FilterScopeSelector({
dashboardFilters,
layout,
updateDashboardFiltersScope,
setUnsavedChanges,
onCloseModal,
}: FilterScopeSelectorProps): ReactElement {
const initialized = useMemo(
() => initializeState(dashboardFilters, layout),
// Only initialize once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const { showSelector, allFilterFields } = initialized;
const [activeFilterField, setActiveFilterField] = useState<string | null>(
() =>
initialized.showSelector
? initialized.initialState.activeFilterField
: null,
);
const [searchText, setSearchText] = useState(() =>
initialized.showSelector ? initialized.initialState.searchText : '',
);
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
);
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
);
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
() =>
initialized.showSelector
? initialized.initialState.checkedFilterFields
: [],
);
const [expandedFilterIds, setExpandedFilterIds] = useState<
(string | number)[]
>(() =>
initialized.showSelector ? initialized.initialState.expandedFilterIds : [],
);
const filterNodes = useCallback(
(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
currentSearchText: string,
): FilterScopeTreeNode[] => {
const filterNodesRecursive = (
f: FilterScopeTreeNode[],
n: FilterScopeTreeNode,
): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText);
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
filterNodesRecursive,
[],
});
this.defaultFilterKey = String(
filtersNodes[0]?.children?.[0]?.value ?? '',
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
},
[],
);
const filterTree = useCallback(
(currentSearchText: string) => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
// Reset nodes back to unfiltered state
if (!currentSearchText) {
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
nodesFiltered: prev[key].nodes,
},
}));
} else {
setFilterScopeMap(prev => {
const nodesFiltered = prev[key].nodes.reduce<FilterScopeTreeNode[]>(
(filtered, node) => filterNodes(filtered, node, currentSearchText),
[],
);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
// build FilterScopeTree object for each filterKey
const filterScopeMap: FilterScopeMap = Object.values(
dashboardFilters,
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(
columns,
).reduce<FilterScopeMap>((mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: String(filterId),
column: columnName,
});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter((id: number) => id !== filterId);
return {
...prev,
[key]: {
...prev[key],
nodesFiltered,
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
});
}
},
[activeFilterField, checkedFilterFields, filterNodes],
);
}, {});
const onCheckFilterScope = useCallback(
(checked: (string | number)[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
return {
...map,
...filterScopeByChartId,
};
}, {});
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(
this.defaultFilterKey,
);
const checkedFilterFields: string[] = [];
const activeFilterField = this.defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [
ALL_FILTERS_ROOT,
chartId,
];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
activeFilterField,
filterScopeMap,
layout,
});
this.state = {
showSelector: true,
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
};
} else {
this.state = {
showSelector: false,
};
}
setFilterScopeMap({
this.filterNodes = this.filterNodes.bind(this);
this.onChangeFilterField = this.onChangeFilterField.bind(this);
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
this.onSearchInputChange = this.onSearchInputChange.bind(this);
this.onCheckFilterField = this.onCheckFilterField.bind(this);
this.onExpandFilterField = this.onExpandFilterField.bind(this);
this.onClose = this.onClose.bind(this);
this.onSave = this.onSave.bind(this);
}
onCheckFilterScope(checked: (string | number)[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, filterScopeMap, checkedFilterFields } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedEntry = {
...filterScopeMap[key],
checked,
};
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
...updatedFilterScopeMap,
[key]: {
...filterScopeMap[key],
checked,
},
} as FilterScopeMap);
},
[activeFilterField, checkedFilterFields, filterScopeMap],
);
[key]: updatedEntry,
} as FilterScopeMap,
}));
}
const onExpandFilterScope = useCallback(
(expanded: string[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
onExpandFilterScope(expanded: string[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
expanded,
};
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
}));
}
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
expanded,
},
}));
},
[activeFilterField, checkedFilterFields],
);
onCheckFilterField(checkedFilterFields: string[] = []): void {
const { layout } = this.props;
const state = this.state as FilterScopeSelectorStateWithSelector;
const { filterScopeMap } = state;
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
const onCheckFilterField = useCallback(
(newCheckedFilterFields: string[] = []): void => {
this.setState(() => ({
activeFilterField: null,
checkedFilterFields,
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
},
}));
}
onExpandFilterField(expandedFilterIds: (string | number)[] = []): void {
this.setState(() => ({
expandedFilterIds,
}));
}
onChangeFilterField(filterField: { value?: string } = {}): void {
const { layout } = this.props;
const nextActiveFilterField = filterField.value;
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField: currentActiveFilterField,
checkedFilterFields,
filterScopeMap,
} = state;
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === currentActiveFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields: newCheckedFilterFields,
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
setActiveFilterField(null);
setCheckedFilterFields(newCheckedFilterFields);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
this.setState({
activeFilterField: null,
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
});
} else if (
nextActiveFilterField &&
this.allfilterFields.includes(nextActiveFilterField)
) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: nextActiveFilterField,
filterScopeMap,
layout,
});
},
[filterScopeMap, layout],
);
const onExpandFilterField = useCallback(
(newExpandedFilterIds: (string | number)[] = []): void => {
setExpandedFilterIds(newExpandedFilterIds);
},
[],
);
const onChangeFilterField = useCallback(
(filterField: { value?: string } = {}): void => {
const nextActiveFilterField = filterField.value;
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === activeFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
setActiveFilterField(null);
setFilterScopeMap({
this.setState({
activeFilterField: nextActiveFilterField,
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
});
} else if (
nextActiveFilterField &&
allFilterFields.includes(nextActiveFilterField)
) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: nextActiveFilterField,
filterScopeMap,
layout,
});
} as FilterScopeMap,
});
}
}
setActiveFilterField(nextActiveFilterField);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
});
}
},
[
activeFilterField,
allFilterFields,
checkedFilterFields,
filterScopeMap,
layout,
],
);
onSearchInputChange(e: ChangeEvent<HTMLInputElement>): void {
this.setState({ searchText: e.target.value }, this.filterTree);
}
const onSearchInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
const newSearchText = e.target.value;
setSearchText(newSearchText);
filterTree(newSearchText);
},
[filterTree],
);
onClose(): void {
this.props.onCloseModal();
}
const onClose = useCallback((): void => {
onCloseModal();
}, [onCloseModal]);
onSave(): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { filterScopeMap } = state;
const onSave = useCallback((): void => {
const allFilterFieldScopes = allFilterFields.reduce<
const allFilterFieldScopes = this.allfilterFields.reduce<
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
>((map, filterKey) => {
const { nodes } = filterScopeMap[filterKey];
@@ -737,32 +669,124 @@ export default function FilterScopeSelector({
};
}, {});
updateDashboardFiltersScope(allFilterFieldScopes);
setUnsavedChanges(true);
this.props.updateDashboardFiltersScope(allFilterFieldScopes);
this.props.setUnsavedChanges(true);
// click Save button will do save and close modal
onCloseModal();
}, [
allFilterFields,
filterScopeMap,
onCloseModal,
setUnsavedChanges,
updateDashboardFiltersScope,
]);
this.props.onCloseModal();
}
const renderFilterFieldList = (): ReactElement | null => (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={onChangeFilterField}
onCheck={onCheckFilterField}
onExpand={onExpandFilterField}
/>
);
filterTree(): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
// Reset nodes back to unfiltered state
if (!state.searchText) {
this.setState(prevState => {
const prev = prevState as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered: filterScopeMap[key].nodes,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
});
} else {
const updater = (
prevState: FilterScopeSelectorState,
): FilterScopeSelectorState => {
const prev = prevState as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const nodesFiltered = filterScopeMap[key].nodes.reduce<
FilterScopeTreeNode[]
>(this.filterNodes, []);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered,
expanded,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
};
this.setState(updater);
}
}
filterNodes(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
): FilterScopeTreeNode[] {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { searchText } = state;
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
this.filterNodes,
[],
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((searchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
}
renderFilterFieldList(): ReactElement | null {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
} = state;
return (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={this.onChangeFilterField}
onCheck={this.onCheckFilterField}
onExpand={this.onExpandFilterField}
/>
);
}
renderFilterScopeTree(): ReactElement {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
filterScopeMap,
activeFilterField,
checkedFilterFields,
searchText,
} = state;
const renderFilterScopeTree = (): ReactElement => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
@@ -779,23 +803,26 @@ export default function FilterScopeSelector({
placeholder={t('Search...')}
type="text"
value={searchText}
onChange={onSearchInputChange}
onChange={this.onSearchInputChange}
/>
<FilterScopeTree
nodes={filterScopeMap[key].nodesFiltered}
checked={filterScopeMap[key].checked}
expanded={filterScopeMap[key].expanded}
onCheck={onCheckFilterScope}
onExpand={onExpandFilterScope}
onCheck={this.onCheckFilterScope}
onExpand={this.onExpandFilterScope}
// pass selectedFilterId prop to FilterScopeTree component,
// to hide checkbox for selected filter field itself
selectedChartId={selectedChartId}
/>
</>
);
};
}
const renderEditingFiltersName = (): ReactElement => {
renderEditingFiltersName(): ReactElement {
const { dashboardFilters } = this.props;
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields } = state;
const currentFilterLabels = ([] as string[])
.concat(activeFilterField || checkedFilterFields)
.filter(Boolean)
@@ -815,42 +842,50 @@ export default function FilterScopeSelector({
</span>
</div>
);
};
}
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && renderEditingFiltersName()}
</ScopeHeader>
render(): ReactElement {
const { showSelector } = this.state;
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
</div>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{renderFilterFieldList()}
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && this.renderEditingFiltersName()}
</ScopeHeader>
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
</div>
<div className="filter-scope-pane multi-edit-mode">
{renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{this.renderFilterFieldList()}
</div>
<div className="filter-scope-pane multi-edit-mode">
{this.renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<ActionsContainer>
<Button buttonSize="small" onClick={onClose}>
{t('Close')}
</Button>
{showSelector && (
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
{t('Save')}
<ActionsContainer>
<Button buttonSize="small" onClick={this.onClose}>
{t('Close')}
</Button>
)}
</ActionsContainer>
</ScopeContainer>
);
{showSelector && (
<Button
buttonSize="small"
buttonStyle="primary"
onClick={this.onSave}
>
{t('Save')}
</Button>
)}
</ActionsContainer>
</ScopeContainer>
);
}
}

View File

@@ -128,10 +128,6 @@ const SliceContainer = styled.div`
const EMPTY_OBJECT: Record<string, never> = {};
// Stable no-op fallback for optional callbacks so we don't allocate a new
// function on every render (keeps referential equality for memoized children).
const NOOP = () => {};
// Helper function to get chart state with fallback
const getChartStateWithFallback = (
chartState: { state?: JsonObject } | undefined,
@@ -767,11 +763,11 @@ const Chart = (props: ChartProps) => {
},
slice.viz_type,
)}
queriesResponse={chart.queriesResponse ?? null}
queriesResponse={chart.queriesResponse ?? undefined}
timeout={timeout}
triggerQuery={chart.triggerQuery}
vizType={slice.viz_type}
setControlValue={props.setControlValue ?? NOOP}
setControlValue={props.setControlValue}
datasetsStatus={
datasetsStatus as 'loading' | 'error' | 'complete' | undefined
}

View File

@@ -17,8 +17,9 @@
* under the License.
*/
import { useCallback, memo } from 'react';
import { PureComponent } from 'react';
import { css, styled } from '@apache-superset/core/theme';
import { Draggable } from '../../dnd/DragDroppable';
import HoverMenu from '../../menu/HoverMenu';
import DeleteComponentButton from '../../DeleteComponentButton';
@@ -62,43 +63,50 @@ const DividerLine = styled.div`
`}
`;
function Divider({
id,
parentId,
component,
depth,
parentComponent,
index,
editMode,
handleComponentDrop,
deleteComponent,
}: DividerProps) {
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
}, [deleteComponent, id, parentId]);
class Divider extends PureComponent<DividerProps> {
constructor(props: DividerProps) {
super(props);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
}
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
handleDeleteComponent() {
const { deleteComponent, id, parentId } = this.props;
deleteComponent(id, parentId);
}
render() {
const {
component,
depth,
parentComponent,
index,
handleComponentDrop,
editMode,
} = this.props;
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
}
export default memo(Divider);
export default Divider;

View File

@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useCallback, memo } from 'react';
import { PureComponent } from 'react';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
import { EditableTitle } from '@superset-ui/core/components';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -84,6 +85,10 @@ interface HeaderProps {
updateComponents: (changes: Record<string, ComponentShape>) => void;
}
interface HeaderState {
isFocused: boolean;
}
const HeaderStyles = styled.div`
${({ theme }) => css`
font-weight: ${theme.fontWeightStrong};
@@ -154,141 +159,149 @@ const HeaderStyles = styled.div`
`}
`;
function Header({
id,
dashboardId,
parentId,
component,
depth,
parentComponent,
index,
editMode,
embeddedMode,
handleComponentDrop,
deleteComponent,
updateComponents,
}: HeaderProps) {
const [isFocused, setIsFocused] = useState(false);
class Header extends PureComponent<HeaderProps, HeaderState> {
handleChangeSize: (nextValue: string) => void;
handleChangeBackground: (nextValue: string) => void;
handleChangeText: (nextValue: string) => void;
const handleChangeFocus = useCallback((nextFocus: boolean): void => {
setIsFocused(nextFocus);
}, []);
constructor(props: HeaderProps) {
super(props);
this.state = {
isFocused: false,
};
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
const handleUpdateMeta = useCallback(
(metaKey: keyof ComponentMeta, nextValue: string): void => {
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
},
this.handleChangeSize = (nextValue: string) =>
this.handleUpdateMeta('headerSize', nextValue);
this.handleChangeBackground = (nextValue: string) =>
this.handleUpdateMeta('background', nextValue);
this.handleChangeText = (nextValue: string) =>
this.handleUpdateMeta('text', nextValue);
}
handleChangeFocus(nextFocus: boolean): void {
this.setState(() => ({ isFocused: nextFocus }));
}
handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void {
const { updateComponents, component } = this.props;
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
},
} as Record<string, ComponentShape>);
}
},
[component, updateComponents],
);
},
} as Record<string, ComponentShape>);
}
}
const handleChangeSize = useCallback(
(nextValue: string) => handleUpdateMeta('headerSize', nextValue),
[handleUpdateMeta],
);
const handleChangeBackground = useCallback(
(nextValue: string) => handleUpdateMeta('background', nextValue),
[handleUpdateMeta],
);
const handleChangeText = useCallback(
(nextValue: string) => handleUpdateMeta('text', nextValue),
[handleUpdateMeta],
);
const handleDeleteComponent = useCallback((): void => {
handleDeleteComponent(): void {
const { deleteComponent, id, parentId } = this.props;
deleteComponent(id, parentId);
}, [deleteComponent, id, parentId]);
}
const headerStyle = headerStyleOptions.find(
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
);
render() {
const { isFocused } = this.state;
const rowStyle = backgroundStyleOptions.find(
opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
);
const {
dashboardId,
component,
depth,
parentComponent,
index,
handleComponentDrop,
editMode,
embeddedMode,
} = this.props;
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
</HoverMenu>
)}
<WithPopoverMenu
onChangeFocus={handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
const headerStyle = headerStyleOptions.find(
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
);
const rowStyle = backgroundStyleOptions.find(
opt =>
opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
);
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
</HoverMenu>
)}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={handleChangeText}
showTooltip={false}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={this.handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={this.handleChangeText}
showTooltip={false}
/>
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
/>
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
}
export default memo(Header);
export default Header;

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