mirror of
https://github.com/apache/superset.git
synced 2026-06-30 11:55:31 +00:00
Compare commits
123 Commits
fire-alert
...
chart-samp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48958cc8ce | ||
|
|
7dac6c1d4f | ||
|
|
a1297d10ac | ||
|
|
35365d639d | ||
|
|
7e17c70cba | ||
|
|
0d43c2c12c | ||
|
|
7410ff73c0 | ||
|
|
f08f068240 | ||
|
|
2b09b6bc1d | ||
|
|
d763255e15 | ||
|
|
8fed514e79 | ||
|
|
c94bc7178f | ||
|
|
95ecdd3753 | ||
|
|
aac02ab679 | ||
|
|
de01fe2ff0 | ||
|
|
9965c05699 | ||
|
|
d8bcc66472 | ||
|
|
4b9b8187b3 | ||
|
|
83f7dc9d5b | ||
|
|
baca76ebe0 | ||
|
|
9a11c15a33 | ||
|
|
a90c8e0347 | ||
|
|
fe2424ec14 | ||
|
|
b4f43bd7e0 | ||
|
|
2b25345ed9 | ||
|
|
e0f3f93cd4 | ||
|
|
0667ba6097 | ||
|
|
81f7e42f4e | ||
|
|
0fd244b5c6 | ||
|
|
1f16d10cbf | ||
|
|
4f4663418f | ||
|
|
4519a5c52d | ||
|
|
da9fbadaf6 | ||
|
|
f40abbbefd | ||
|
|
6166af3c3c | ||
|
|
076d8c1508 | ||
|
|
518cadd907 | ||
|
|
b955c90de4 | ||
|
|
7363774869 | ||
|
|
6f12d17313 | ||
|
|
09c7ba14df | ||
|
|
3ec4bd23c4 | ||
|
|
f6ce105450 | ||
|
|
7bb4e82a82 | ||
|
|
2d78a8733c | ||
|
|
3261d10270 | ||
|
|
a57b5f6078 | ||
|
|
d1b523b97f | ||
|
|
91188a0302 | ||
|
|
ac234d0fb2 | ||
|
|
8eb753eab2 | ||
|
|
779fa13679 | ||
|
|
caf81e71d2 | ||
|
|
1b8c6d109d | ||
|
|
eb60e5477b | ||
|
|
7b9bcdd951 | ||
|
|
d9d395bde1 | ||
|
|
584d41759b | ||
|
|
8f22b71898 | ||
|
|
1ea3584dcb | ||
|
|
6bc77fecc2 | ||
|
|
420a74b01e | ||
|
|
7ba59c2d79 | ||
|
|
b77c525d4b | ||
|
|
41ce9ca7d3 | ||
|
|
c2fb94cedf | ||
|
|
1d0866556f | ||
|
|
b4dfeef2fd | ||
|
|
0ec6cae45d | ||
|
|
d6ede99861 | ||
|
|
9b6d3ce775 | ||
|
|
c1f4062af6 | ||
|
|
3bc3f47d67 | ||
|
|
acb996a324 | ||
|
|
c1d08bf27c | ||
|
|
d3d5297025 | ||
|
|
b1470bd5a5 | ||
|
|
18fea37e84 | ||
|
|
1b71c105b7 | ||
|
|
b061b5d317 | ||
|
|
386893f9f2 | ||
|
|
c1787a67aa | ||
|
|
dee5859599 | ||
|
|
1d3daf2ac8 | ||
|
|
9d56b1721d | ||
|
|
67182e255c | ||
|
|
e2c6dc3e1a | ||
|
|
c539ae98ba | ||
|
|
ca3c420412 | ||
|
|
5e8a0c0244 | ||
|
|
90fa31f305 | ||
|
|
5731d0874a | ||
|
|
66f5ab2d2f | ||
|
|
36b0ed023b | ||
|
|
3ff90bd532 | ||
|
|
5d06438a07 | ||
|
|
eb0d4dd601 | ||
|
|
92109f0f99 | ||
|
|
9431381c3e | ||
|
|
b94f90e39e | ||
|
|
714c5cd075 | ||
|
|
c65c0951cf | ||
|
|
ae5c08b993 | ||
|
|
b9c61a079d | ||
|
|
2599bea0c2 | ||
|
|
6c70f3d275 | ||
|
|
da893462b8 | ||
|
|
18853c6ecf | ||
|
|
8768e5be0f | ||
|
|
133473d0f4 | ||
|
|
5916ec4876 | ||
|
|
36781fbf47 | ||
|
|
215b207ae4 | ||
|
|
3b46a5f121 | ||
|
|
416fa266d9 | ||
|
|
f70a2eac89 | ||
|
|
c49391ab08 | ||
|
|
0fbace5b5d | ||
|
|
c55c85f824 | ||
|
|
e34b7c2daf | ||
|
|
eac5bd23bd | ||
|
|
27a65257ee | ||
|
|
932bb2f154 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -38,7 +38,7 @@
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke @rusackas
|
||||
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
|
||||
2
.github/workflows/bump-python-package.yml
vendored
2
.github/workflows/bump-python-package.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
checks: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
ref: master
|
||||
|
||||
2
.github/workflows/check-python-deps.yml
vendored
2
.github/workflows/check-python-deps.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check and notify
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/dependency-review.yml
vendored
4
.github/workflows/dependency-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Dependency Review"
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
docker: ${{ steps.check.outputs.docker }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Free up disk space
|
||||
|
||||
2
.github/workflows/embedded-sdk-release.yml
vendored
2
.github/workflows/embedded-sdk-release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Note: registry-url is intentionally omitted. When set, actions/setup-node
|
||||
|
||||
2
.github/workflows/embedded-sdk-test.yml
vendored
2
.github/workflows/embedded-sdk-test.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
run:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
|
||||
4
.github/workflows/generate-FOSSA-report.yml
vendored
4
.github/workflows/generate-FOSSA-report.yml
vendored
@@ -32,12 +32,12 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/issue_creation.yml
vendored
2
.github/workflows/issue_creation.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/latest-release-tag.yml
vendored
2
.github/workflows/latest-release-tag.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/license-check.yml
vendored
4
.github/workflows/license-check.yml
vendored
@@ -18,12 +18,12 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
2
.github/workflows/pr-lint.yml
vendored
2
.github/workflows/pr-lint.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# pulls all commits (needed for lerna / semantic release to correctly version)
|
||||
|
||||
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
Normal file
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
name: Scheduled Docker image refresh
|
||||
|
||||
# Re-runs the Docker image build against the latest published release on a
|
||||
# weekly cadence. The code being built doesn't change — but the base image
|
||||
# layers (python:*-slim-trixie and its OS packages) DO get upstream
|
||||
# security patches between Superset releases, and those patches don't
|
||||
# reach our published images unless we rebuild.
|
||||
#
|
||||
# Without this workflow, `apache/superset:<latest>` lags behind upstream
|
||||
# Debian/Python base patches by whatever interval falls between Superset
|
||||
# releases (typically 3–6 weeks). With it, the lag drops to at most one
|
||||
# week regardless of release cadence.
|
||||
#
|
||||
# This is a security-hygiene cron, not a release. It overwrites the
|
||||
# existing tags for the most recent release (e.g. `apache/superset:5.0.0`
|
||||
# and `apache/superset:latest`) with bit-for-bit-equivalent contents
|
||||
# layered on a refreshed base. Image digests change; everything users
|
||||
# actually pin against (image content, code, deps) does not.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays at 06:00 UTC — gives the weekend for upstream patches to
|
||||
# settle and surfaces failures at the start of the work week so a
|
||||
# human can react.
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
# Manual trigger so operators can force a refresh on demand (e.g.
|
||||
# immediately after a high-severity base-image CVE drops).
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize with itself and with the release publisher (tag-release.yml) —
|
||||
# both push to the same Docker Hub tags, so a race could end with stale
|
||||
# layers winning. Both workflows must declare this group for the lock to work.
|
||||
concurrency:
|
||||
group: docker-publish-latest-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
has-secrets: ${{ steps.check.outputs.has-secrets }}
|
||||
latest-release: ${{ steps.latest.outputs.tag }}
|
||||
force-latest: ${{ steps.latest.outputs.force-latest }}
|
||||
steps:
|
||||
- name: Check for Docker Hub secrets
|
||||
id: check
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${DOCKERHUB_USER}" ]; then
|
||||
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }}
|
||||
|
||||
- name: Look up latest published release
|
||||
id: latest
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
# `releases/latest` returns the latest non-prerelease, non-draft
|
||||
# release — which is exactly what `apache/superset:latest`
|
||||
# should reflect.
|
||||
TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name)
|
||||
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
|
||||
echo "::error::Could not determine latest release tag"
|
||||
exit 1
|
||||
fi
|
||||
echo "Latest release: $TAG"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Only move `:latest` when the release flagged "latest" is also the
|
||||
# highest semver release. This guards against a mis-click leaving an
|
||||
# older maintenance release (e.g. a 5.x patch shipped after 6.0 GA)
|
||||
# marked latest, which would otherwise roll `:latest` back a major
|
||||
# version on the next cron run. If it isn't the newest, we still
|
||||
# refresh that release's own version tag but leave `:latest` alone.
|
||||
HIGHEST=$(gh api --paginate "repos/${REPOSITORY}/releases" \
|
||||
--jq '.[] | select(.draft|not) | select(.prerelease|not) | .tag_name' \
|
||||
| sed 's/^v//' | sort -V | tail -n1)
|
||||
if [ "${TAG#v}" = "$HIGHEST" ]; then
|
||||
echo "force-latest=1" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::Latest-flagged release $TAG is not the highest semver ($HIGHEST); refreshing its version tag but leaving :latest untouched"
|
||||
fi
|
||||
|
||||
docker-rebuild:
|
||||
needs: config
|
||||
if: needs.config.outputs.has-secrets == '1'
|
||||
name: docker-rebuild
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
# Mirror the same matrix the release publisher uses so every variant
|
||||
# operators consume from Docker Hub gets the refreshed base.
|
||||
matrix:
|
||||
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ needs.config.outputs.latest-release }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Docker Environment
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
install-docker-compose: "false"
|
||||
build: "true"
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Rebuild and push
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_PRESET: ${{ matrix.build_preset }}
|
||||
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
|
||||
FORCE_LATEST_FLAG: ${{ needs.config.outputs.force-latest == '1' && '--force-latest' || '' }}
|
||||
run: |
|
||||
# Reuses the same supersetbot invocation as the release
|
||||
# publisher (`tag-release.yml`), so the resulting tags are
|
||||
# identical to what a manual release dispatch would produce —
|
||||
# just with a freshly-pulled base image layer underneath.
|
||||
# `--force-latest` is only passed when the config job confirmed the
|
||||
# fetched release is the newest one (see FORCE_LATEST_FLAG above).
|
||||
supersetbot docker \
|
||||
--push \
|
||||
--preset "$BUILD_PRESET" \
|
||||
--context release \
|
||||
--context-ref "$LATEST_RELEASE" \
|
||||
$FORCE_LATEST_FLAG \
|
||||
--platform "linux/arm64" \
|
||||
--platform "linux/amd64"
|
||||
|
||||
# The whole point of this cron is catching base-image CVEs, so a silent
|
||||
# failure is the expensive case — a red X in the Actions tab nobody is
|
||||
# watching on a Monday. File a tracked issue when any rebuild leg fails so
|
||||
# a missed security refresh surfaces instead of sitting unnoticed.
|
||||
notify-on-failure:
|
||||
needs: [config, docker-rebuild]
|
||||
if: failure() && needs.config.outputs.has-secrets == '1'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Open a tracking issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
gh issue create \
|
||||
--repo "$REPOSITORY" \
|
||||
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
|
||||
--label "infra:container" \
|
||||
--label "bug" \
|
||||
--body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved.
|
||||
|
||||
Failed run: ${RUN_URL}"
|
||||
2
.github/workflows/showtime-trigger.yml
vendored
2
.github/workflows/showtime-trigger.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
|
||||
- name: Checkout PR code (only if build needed)
|
||||
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.check.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/superset-app-cli.yml
vendored
2
.github/workflows/superset-app-cli.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/superset-docs-deploy.yml
vendored
4
.github/workflows/superset-docs-deploy.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
persist-credentials: false
|
||||
@@ -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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
|
||||
6
.github/workflows/superset-docs-verify.yml
vendored
6
.github/workflows/superset-docs-verify.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
name: Link Checking
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Do not bump this linkinator-action version without opening
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: "Checkout PR head: ${{ github.event.workflow_run.head_sha }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
14
.github/workflows/superset-e2e.yml
vendored
14
.github/workflows/superset-e2e.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -97,21 +97,21 @@ jobs:
|
||||
# Conditional checkout based on context
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
@@ -207,21 +207,21 @@ jobs:
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
working-directory: superset-extensions-cli
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/superset-frontend.yml
vendored
4
.github/workflows/superset-frontend.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
should-run: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/superset-helm-lint.yml
vendored
2
.github/workflows/superset-helm-lint.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-helm-release.yml
vendored
2
.github/workflows/superset-helm-release.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
persist-credentials: true
|
||||
|
||||
8
.github/workflows/superset-playwright.yml
vendored
8
.github/workflows/superset-playwright.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -83,21 +83,21 @@ jobs:
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/superset-translations.yml
vendored
4
.github/workflows/superset-translations.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-websocket.yml
vendored
2
.github/workflows/superset-websocket.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/supersetbot.yml
vendored
2
.github/workflows/supersetbot.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
});
|
||||
|
||||
- name: "Checkout ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# zizmor: ignore[artipacked] - required persisted credentials to push synced requirement changes back to remote
|
||||
- name: Checkout source code
|
||||
if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: true
|
||||
|
||||
10
.github/workflows/tag-release.yml
vendored
10
.github/workflows/tag-release.yml
vendored
@@ -24,6 +24,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize with the scheduled Docker image refresh — both workflows push
|
||||
# to the same Docker Hub tags and must not race on apache/superset:latest.
|
||||
concurrency:
|
||||
group: docker-publish-latest-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -54,7 +60,7 @@ jobs:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -120,7 +126,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/tech-debt.yml
vendored
2
.github/workflows/tech-debt.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
name: Generate Reports
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ categories:
|
||||
- name: Clark.de
|
||||
url: https://clark.de/
|
||||
|
||||
- name: Cover Genius
|
||||
url: https://covergenius.com/
|
||||
|
||||
- name: EnquiryLabs
|
||||
url: https://www.enquirylabs.co.uk
|
||||
|
||||
@@ -92,6 +95,10 @@ categories:
|
||||
- name: KarrotPay
|
||||
url: https://www.daangnpay.com/
|
||||
|
||||
- name: NICE Actimize
|
||||
url: https://www.niceactimize.com/
|
||||
contributors: ["@stevensuting"]
|
||||
|
||||
- name: Remita
|
||||
url: https://remita.net
|
||||
contributors: ["@mujibishola"]
|
||||
@@ -112,9 +119,6 @@ categories:
|
||||
url: https://xendit.co/
|
||||
contributors: ["@LieAlbertTriAdrian"]
|
||||
|
||||
- name: Cover Genius
|
||||
url: https://covergenius.com/
|
||||
|
||||
Gaming:
|
||||
- name: Popoko VM Games Studio
|
||||
url: https://popoko.live
|
||||
@@ -296,7 +300,6 @@ categories:
|
||||
logo: hifadih.png
|
||||
contributors: ["@saintLaurent00"]
|
||||
|
||||
# Logo approved by @anmol-hpe on behalf of HPE
|
||||
- name: HPE
|
||||
url: https://www.hpe.com/in/en/home.html
|
||||
logo: hpe.png
|
||||
@@ -429,6 +432,10 @@ categories:
|
||||
logo: userguiding.svg
|
||||
contributors: ["@tzercin"]
|
||||
|
||||
- name: Value Ad
|
||||
url: https://bestpair.info/
|
||||
contributors: ["@stevensuting"]
|
||||
|
||||
- name: Virtuoso QA
|
||||
url: https://www.virtuosoqa.com
|
||||
|
||||
@@ -513,10 +520,6 @@ categories:
|
||||
url: https://www.sunbird.org/
|
||||
contributors: ["@eksteporg"]
|
||||
|
||||
- name: The GRAPH Network
|
||||
url: https://thegraphnetwork.org/
|
||||
contributors: ["@fccoelho"]
|
||||
|
||||
- name: Udemy
|
||||
url: https://www.udemy.com/
|
||||
contributors: ["@sungjuly"]
|
||||
@@ -525,7 +528,24 @@ categories:
|
||||
url: https://www.vipkid.com.cn/
|
||||
contributors: ["@illpanda"]
|
||||
|
||||
- name: WikiMedia Foundation
|
||||
Social Organization:
|
||||
- name: Living Goods
|
||||
url: https://www.livinggoods.org
|
||||
contributors: ["@chelule"]
|
||||
|
||||
- name: One Acre Fund
|
||||
url: https://oneacrefund.org/
|
||||
contributors: ["@stevensuting"]
|
||||
|
||||
- name: Quest Alliance
|
||||
url: https://www.questalliance.net/
|
||||
contributors: ["@stevensuting"]
|
||||
|
||||
- name: The GRAPH Network
|
||||
url: https://thegraphnetwork.org/
|
||||
contributors: ["@fccoelho"]
|
||||
|
||||
- name: Wikimedia Foundation
|
||||
url: https://wikimediafoundation.org
|
||||
contributors: ["@vg"]
|
||||
|
||||
@@ -538,6 +558,10 @@ categories:
|
||||
url: https://www.douroeci.com/
|
||||
contributors: ["@nunohelibeires"]
|
||||
|
||||
- name: Rogow
|
||||
url: https://rogow.com.br/
|
||||
contributors: ["@nilmonto"]
|
||||
|
||||
- name: Safaricom
|
||||
url: https://www.safaricom.co.ke/
|
||||
contributors: ["@mmutiso"]
|
||||
@@ -550,11 +574,10 @@ categories:
|
||||
url: https://wattbewerb.de/
|
||||
contributors: ["@wattbewerb"]
|
||||
|
||||
- name: Rogow
|
||||
url: https://rogow.com.br/
|
||||
contributors: ["@nilmonto"]
|
||||
|
||||
Healthcare:
|
||||
- name: 2070Health
|
||||
url: https://2070health.com/
|
||||
|
||||
- name: Amino
|
||||
url: https://amino.com
|
||||
contributors: ["@shkr"]
|
||||
@@ -567,10 +590,6 @@ categories:
|
||||
url: https://www.getcare.io/
|
||||
contributors: ["@alandao2021"]
|
||||
|
||||
- name: Living Goods
|
||||
url: https://www.livinggoods.org
|
||||
contributors: ["@chelule"]
|
||||
|
||||
- name: Maieutical Labs
|
||||
url: https://maieuticallabs.it
|
||||
contributors: ["@xrmx"]
|
||||
@@ -589,10 +608,10 @@ categories:
|
||||
- name: WeSure
|
||||
url: https://www.wesure.cn/
|
||||
|
||||
- name: 2070Health
|
||||
url: https://2070health.com/
|
||||
|
||||
HR / Staffing:
|
||||
- name: bluquist
|
||||
url: https://bluquist.com/
|
||||
|
||||
- name: Swile
|
||||
url: https://www.swile.co/
|
||||
contributors: ["@PaoloTerzi"]
|
||||
@@ -600,21 +619,18 @@ categories:
|
||||
- name: Symmetrics
|
||||
url: https://www.symmetrics.fyi
|
||||
|
||||
- name: bluquist
|
||||
url: https://bluquist.com/
|
||||
|
||||
Government:
|
||||
- name: City of Ann Arbor, MI
|
||||
url: https://www.a2gov.org/
|
||||
contributors: ["@sfirke"]
|
||||
|
||||
- name: NRLM - Sarathi, India
|
||||
url: https://pib.gov.in/PressReleasePage.aspx?PRID=1999586
|
||||
|
||||
- name: RIS3 Strategy of CZ, MIT CR
|
||||
url: https://www.ris3.cz/
|
||||
contributors: ["@RIS3CZ"]
|
||||
|
||||
- name: NRLM - Sarathi, India
|
||||
url: https://pib.gov.in/PressReleasePage.aspx?PRID=1999586
|
||||
|
||||
Mobile Software:
|
||||
- name: VLMedia
|
||||
url: https://www.vlmedia.com.tr
|
||||
|
||||
12
UPDATING.md
12
UPDATING.md
@@ -24,6 +24,18 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Guest-token RLS rules reject unknown fields
|
||||
|
||||
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||
|
||||
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
|
||||
|
||||
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
|
||||
|
||||
### Swagger UI is opt-in (off by default)
|
||||
|
||||
`FAB_API_SWAGGER_UI` now defaults to `False` and is driven by the `SUPERSET_ENABLE_SWAGGER_UI` environment variable. The interactive Swagger UI / OpenAPI documentation endpoints (e.g. `/swagger/v1`) are therefore no longer exposed by default. To enable them, set `SUPERSET_ENABLE_SWAGGER_UI=true` (the bundled Docker development environment sets this) or override `FAB_API_SWAGGER_UI = True` in `superset_config.py`.
|
||||
|
||||
### Pivot table First/Last aggregations follow data order
|
||||
|
||||
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.
|
||||
|
||||
@@ -72,20 +72,23 @@ services:
|
||||
- -c
|
||||
- |
|
||||
url="http://host.docker.internal:9000/static/assets/manifest.json"
|
||||
max_attempts=150 # ~5 minutes at 2s intervals
|
||||
echo "Waiting for webpack dev server at $url..."
|
||||
max_attempts=300 # ~10 minutes at 2s intervals; first build can be slow
|
||||
echo "Waiting for webpack dev server at $$url..."
|
||||
attempt=0
|
||||
until curl -sf --max-time 5 -o /dev/null "$url"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "ERROR: webpack dev server did not serve $url after $max_attempts attempts (~5 minutes)." >&2
|
||||
until curl -sf --max-time 5 -H "Host: localhost" -o /dev/null "$$url"; do
|
||||
attempt=$$((attempt + 1))
|
||||
if [ "$$attempt" -ge "$$max_attempts" ]; then
|
||||
echo "ERROR: webpack dev server did not serve $$url after $$max_attempts attempts." >&2
|
||||
echo "Is the dev server running? With BUILD_SUPERSET_FRONTEND_IN_DOCKER=false you must start it on the host (e.g. 'npm run dev' in superset-frontend)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $$((attempt % 15)) -eq 0 ]; then
|
||||
echo "Still waiting for webpack dev server... ($$attempt/$$max_attempts)"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Webpack dev server is ready; starting nginx."
|
||||
exec nginx -g 'daemon off;'
|
||||
exec /docker-entrypoint.sh nginx -g 'daemon off;'
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
|
||||
@@ -70,6 +70,8 @@ SUPERSET_LOG_LEVEL=info
|
||||
|
||||
SUPERSET_APP_ROOT="/"
|
||||
SUPERSET_ENV=development
|
||||
# Swagger UI is opt-in (off by default); enable it for local development.
|
||||
SUPERSET_ENABLE_SWAGGER_UI=true
|
||||
SUPERSET_LOAD_EXAMPLES=yes
|
||||
CYPRESS_CONFIG=false
|
||||
SUPERSET_PORT=8088
|
||||
|
||||
@@ -81,17 +81,19 @@ case "${1}" in
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
|
||||
# Environment-based debugger control for security
|
||||
# Only enable Werkzeug interactive debugger when explicitly requested
|
||||
# Modern Werkzeug (3.0+) includes PIN protection, but defense-in-depth approach
|
||||
# Override FLASK_DEBUG so the effective state matches SUPERSET_DEBUG_ENABLED even
|
||||
# when FLASK_DEBUG=true is inherited from docker/.env or .flaskenv
|
||||
# Default to Flask debug mode in this dev compose entrypoint so the Talisman
|
||||
# dev CSP (which permits 'unsafe-eval' required by React Refresh / HMR) is
|
||||
# served. Operators can still set FLASK_DEBUG=false in docker/.env-local
|
||||
# to exercise the production-like CSP and error handling.
|
||||
: "${FLASK_DEBUG:=1}"
|
||||
export FLASK_DEBUG
|
||||
|
||||
# Werkzeug's interactive debugger (/console) is a separate, security-sensitive
|
||||
# feature and must be opted into explicitly via SUPERSET_DEBUG_ENABLED=true.
|
||||
if [[ "${SUPERSET_DEBUG_ENABLED:-}" == "true" ]]; then
|
||||
export FLASK_DEBUG=1
|
||||
DEBUGGER_FLAG="--debugger"
|
||||
echo " ⚠️ Werkzeug debugger enabled (requires PIN for /console access)"
|
||||
else
|
||||
export FLASK_DEBUG=0
|
||||
DEBUGGER_FLAG="--no-debugger"
|
||||
echo " 🔒 Werkzeug debugger disabled (set SUPERSET_DEBUG_ENABLED=true to enable)"
|
||||
fi
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
#
|
||||
HYPHEN_SYMBOL='-'
|
||||
|
||||
gunicorn \
|
||||
exec gunicorn \
|
||||
--bind "${SUPERSET_BIND_ADDRESS:-0.0.0.0}:${SUPERSET_PORT:-8088}" \
|
||||
--access-logfile "${ACCESS_LOG_FILE:-$HYPHEN_SYMBOL}" \
|
||||
--error-logfile "${ERROR_LOG_FILE:-$HYPHEN_SYMBOL}" \
|
||||
|
||||
@@ -161,6 +161,7 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
|
||||
|
||||
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
|
||||
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
|
||||
- [ ] Rotate the other security-critical secrets (guest-token and async-query JWT secrets, SMTP and database credentials) on the cadence in Appendix C, and after any potential security incident.
|
||||
- [ ] Conduct quarterly access reviews for all users.
|
||||
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
|
||||
|
||||
@@ -173,6 +174,24 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
|
||||
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
|
||||
https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
|
||||
|
||||
### **Appendix C: Secrets Register and Rotation Schedule**
|
||||
|
||||
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
|
||||
|
||||
| Secret | Purpose | Risk if leaked | Suggested rotation |
|
||||
|---|---|---|---|
|
||||
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
|
||||
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
|
||||
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
|
||||
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
|
||||
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
|
||||
|
||||
Notes:
|
||||
|
||||
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
|
||||
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
|
||||
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
|
||||
|
||||
:::resources
|
||||
- [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/)
|
||||
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)
|
||||
|
||||
@@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface
|
||||
|
||||
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
```typescript
|
||||
import { views } from '@apache-superset/core';
|
||||
import MyPanel from './MyPanel';
|
||||
|
||||
views.registerView(
|
||||
{ id: 'my-extension.main', name: 'My Panel Name' },
|
||||
'sqllab.panels',
|
||||
() => <MyPanel />,
|
||||
MyPanel,
|
||||
);
|
||||
```
|
||||
|
||||
@@ -112,6 +111,24 @@ editors.registerEditor(
|
||||
|
||||
See [Editors Extension Point](./extension-points/editors.md) for implementation details.
|
||||
|
||||
### Chat
|
||||
|
||||
Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads.
|
||||
|
||||
```tsx
|
||||
import { chat } from '@apache-superset/core';
|
||||
import ChatTrigger from './ChatTrigger';
|
||||
import ChatPanel from './ChatPanel';
|
||||
|
||||
chat.registerChat(
|
||||
{ id: 'my-org.my-chat', name: 'My Chat' },
|
||||
ChatTrigger,
|
||||
ChatPanel,
|
||||
);
|
||||
```
|
||||
|
||||
See [Chat](./extension-points/chat.md) for implementation details.
|
||||
|
||||
## Backend
|
||||
|
||||
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.
|
||||
|
||||
141
docs/developer_docs/extensions/extension-points/chat.md
Normal file
141
docs/developer_docs/extensions/extension-points/chat.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Chat
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Chat Contributions
|
||||
|
||||
Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components.
|
||||
|
||||
## Overview
|
||||
|
||||
A chat registration consists of two React components:
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. |
|
||||
| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. |
|
||||
|
||||
## Display Modes
|
||||
|
||||
The host supports two display modes, switchable by the user or the extension at runtime:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `floating` | Panel floats above page content, anchored to the bottom-right corner. |
|
||||
| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. |
|
||||
|
||||
The user's last selected mode and open/closed state are persisted across page reloads.
|
||||
|
||||
## Registering a Chat
|
||||
|
||||
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
|
||||
|
||||
```tsx
|
||||
import { chat } from '@apache-superset/core';
|
||||
import ChatTrigger from './ChatTrigger';
|
||||
import ChatPanel from './ChatPanel';
|
||||
|
||||
chat.registerChat(
|
||||
{ id: 'my-org.my-chat', name: 'My Chat' },
|
||||
ChatTrigger,
|
||||
ChatPanel,
|
||||
);
|
||||
```
|
||||
|
||||
Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged.
|
||||
|
||||
## Opening and Closing the Chat
|
||||
|
||||
The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility:
|
||||
|
||||
```tsx
|
||||
import { chat } from '@apache-superset/core';
|
||||
|
||||
export default function ChatTrigger() {
|
||||
return (
|
||||
<button onClick={() => (chat.isOpen() ? chat.close() : chat.open())}>
|
||||
💬
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can also subscribe to open/close events from any component:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const { dispose } = chat.onDidOpen(() => console.log('chat opened'));
|
||||
return dispose;
|
||||
}, []);
|
||||
```
|
||||
|
||||
## Changing the Display Mode
|
||||
|
||||
Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user):
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { chat } from '@apache-superset/core';
|
||||
|
||||
export default function ChatPanel() {
|
||||
const [mode, setMode] = useState(chat.getDisplayMode());
|
||||
|
||||
useEffect(() => {
|
||||
const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m));
|
||||
return dispose;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ height: mode === 'panel' ? '100%' : '80vh' }}>
|
||||
<button onClick={() => chat.setDisplayMode(mode === 'panel' ? 'floating' : 'panel')}>
|
||||
{mode === 'panel' ? 'Float' : 'Dock'}
|
||||
</button>
|
||||
{/* message list and input */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Chat API Reference
|
||||
|
||||
All methods are available on the `chat` namespace from `@apache-superset/core`:
|
||||
|
||||
| Method / Event | Description |
|
||||
|----------------|-------------|
|
||||
| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. |
|
||||
| `open()` | Open the chat panel. No-op if already open or no registration. |
|
||||
| `close()` | Close the chat panel. |
|
||||
| `isOpen()` | Returns `true` if the panel is currently open. |
|
||||
| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). |
|
||||
| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. |
|
||||
| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. |
|
||||
| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. |
|
||||
| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. |
|
||||
| `onDidRegisterChat(listener)` | Subscribe to registration events. |
|
||||
| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. |
|
||||
| `onDidResizePanel(listener)` | Subscribe to panel resize events (panel mode only). Not all hosts provide a resizer — do not rely on this firing. Returns a `Disposable`. |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
|
||||
- **[Development](../development.md)** — Set up your development environment
|
||||
@@ -47,6 +47,8 @@ module.exports = {
|
||||
collapsed: true,
|
||||
items: [
|
||||
'extensions/extension-points/sqllab',
|
||||
'extensions/extension-points/editors',
|
||||
'extensions/extension-points/chat',
|
||||
],
|
||||
},
|
||||
'extensions/development',
|
||||
|
||||
@@ -28,14 +28,19 @@
|
||||
# Skip builds when no docs changes (exit 0 = skip, non-zero = build).
|
||||
# Checks for changes in docs/ and README.md (which gets pulled into docs).
|
||||
#
|
||||
# $CACHED_COMMIT_REF is the last *deployed* commit. On a PR's first build it
|
||||
# is empty, so the original `git diff` errored and Netlify fell back to
|
||||
# building -- which is why every PR built a docs preview once even with no
|
||||
# docs changes. When it is empty we instead diff the whole branch against its
|
||||
# merge-base with master, so non-docs PRs are skipped from the very first
|
||||
# build. Subsequent builds (and the master production build) keep the cheaper
|
||||
# incremental $CACHED_COMMIT_REF diff. Any failure exits non-zero -> build.
|
||||
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" "$COMMIT_REF" -- . ../README.md; else git fetch origin master --depth=100 >/dev/null 2>&1; git diff --quiet "$(git merge-base origin/master "$COMMIT_REF" 2>/dev/null || echo origin/master)" "$COMMIT_REF" -- . ../README.md; fi'
|
||||
# $CACHED_COMMIT_REF is the last *deployed* commit; it is set on incremental
|
||||
# builds (notably the master production deploy) and empty on a context's
|
||||
# first build (every deploy preview). The production path diffs against it
|
||||
# and skips correctly.
|
||||
#
|
||||
# Deploy previews need different handling: Netlify checks out a *merge*
|
||||
# commit, so $COMMIT_REF (the PR head SHA) is frequently not resolvable in
|
||||
# the clone, and on a shallow clone `git merge-base` can fail too -- so the
|
||||
# previous logic fell through to a build on every PR, even non-docs ones.
|
||||
# Instead, always diff the checked-out HEAD against its merge-base with
|
||||
# master, deepening the shallow clone until that merge-base resolves. If it
|
||||
# genuinely can't be determined, exit non-zero to build (fail safe).
|
||||
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" HEAD -- . ../README.md; else git fetch --no-tags origin master >/dev/null 2>&1 || true; i=0; while [ "$i" -lt 10 ] && ! git merge-base origin/master HEAD >/dev/null 2>&1; do git fetch --deepen=200 origin master >/dev/null 2>&1 || break; i=$((i+1)); done; BASE="$(git merge-base origin/master HEAD 2>/dev/null || true)"; if [ -z "$BASE" ]; then exit 1; fi; git diff --quiet "$BASE" HEAD -- . ../README.md; fi'
|
||||
|
||||
[build.environment]
|
||||
# Node version matching docs/.nvmrc
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.41",
|
||||
"antd": "^6.4.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
@@ -109,7 +109,7 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.4",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"webpack": "^5.107.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -1808,6 +1808,10 @@ If you enable DML in the meta database users will be able to run DML queries on
|
||||
|
||||
Second, you might want to change the value of `SUPERSET_META_DB_LIMIT`. The default value is 1000, and defines how many are read from each database before any aggregations and joins are executed. You can also set this value `None` if you only have small tables.
|
||||
|
||||
:::warning
|
||||
`SUPERSET_META_DB_LIMIT` is applied to **each** underlying table *before* the in-memory join runs, not to the final result. If any table involved in a join has more rows than the limit, the meta database will read only the first `SUPERSET_META_DB_LIMIT` rows of that table, which means matching rows can be silently dropped and the join can return **incomplete or even empty** results with no error. If you join tables larger than the limit, raise `SUPERSET_META_DB_LIMIT` to comfortably exceed your largest joined table, or set it to `None` when working only with small tables, to get correct results.
|
||||
:::
|
||||
|
||||
Additionally, you might want to restrict the databases to with the meta database has access to. This can be done in the database configuration, under "Advanced" -> "Other" -> "ENGINE PARAMETERS" and adding:
|
||||
|
||||
```json
|
||||
|
||||
158
docs/yarn.lock
158
docs/yarn.lock
@@ -4932,110 +4932,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.61.1", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz#6e4b7fee21f1983308e9e9b634ecbaf702c86006"
|
||||
integrity sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==
|
||||
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.61.1"
|
||||
"@typescript-eslint/type-utils" "8.61.1"
|
||||
"@typescript-eslint/utils" "8.61.1"
|
||||
"@typescript-eslint/visitor-keys" "8.61.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.61.1", "@typescript-eslint/parser@^8.61.0":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.1.tgz#881fba60b50636249cdeea2e547bf75715254c72"
|
||||
integrity sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==
|
||||
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.61.1"
|
||||
"@typescript-eslint/types" "8.61.1"
|
||||
"@typescript-eslint/typescript-estree" "8.61.1"
|
||||
"@typescript-eslint/visitor-keys" "8.61.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.61.1":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.1.tgz#fcd9739964a40867eed55f1ac318d3909f24b4af"
|
||||
integrity sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.61.0"
|
||||
"@typescript-eslint/types" "^8.61.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.61.1"
|
||||
"@typescript-eslint/types" "^8.61.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.61.1":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz#2479921a40fdb0afa18f5838fae6167264b417b2"
|
||||
integrity sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.61.0"
|
||||
"@typescript-eslint/visitor-keys" "8.61.0"
|
||||
"@typescript-eslint/types" "8.61.1"
|
||||
"@typescript-eslint/visitor-keys" "8.61.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.61.0":
|
||||
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":
|
||||
"@typescript-eslint/tsconfig-utils@8.61.1":
|
||||
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/tsconfig-utils@^8.61.1":
|
||||
version "8.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz#9440a673581c6d9de308c4d5803dd52ed5d71729"
|
||||
integrity sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==
|
||||
|
||||
"@typescript-eslint/type-utils@8.61.1":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz#8fa18f453ee140893b47d339d1a6b64cac9b08a1"
|
||||
integrity sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.61.0"
|
||||
"@typescript-eslint/typescript-estree" "8.61.0"
|
||||
"@typescript-eslint/utils" "8.61.0"
|
||||
"@typescript-eslint/types" "8.61.1"
|
||||
"@typescript-eslint/typescript-estree" "8.61.1"
|
||||
"@typescript-eslint/utils" "8.61.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.61.0":
|
||||
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":
|
||||
"@typescript-eslint/types@8.61.1":
|
||||
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/types@^8.61.1":
|
||||
version "8.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.0.tgz#601427c10203d9f0f34f0b3e474df735eb12b593"
|
||||
integrity sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.61.1":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz#febbe70365ac0bf7611262b61b338fc8797965c7"
|
||||
integrity sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==
|
||||
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.61.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.61.1"
|
||||
"@typescript-eslint/types" "8.61.1"
|
||||
"@typescript-eslint/visitor-keys" "8.61.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.61.1":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.61.1.tgz#ffd1054de7dd33b7873cd6c6713ec6b0366316d3"
|
||||
integrity sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==
|
||||
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.61.1"
|
||||
"@typescript-eslint/types" "8.61.1"
|
||||
"@typescript-eslint/typescript-estree" "8.61.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.61.1":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz#546cf102b4efdb72a9a08e63a1b0d7d745eb66eb"
|
||||
integrity sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.61.0"
|
||||
"@typescript-eslint/types" "8.61.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5698,10 +5698,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.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.37"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
|
||||
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.38"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
|
||||
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -14502,15 +14502,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.61.1:
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.61.1.tgz#7c224a9a643b7f42d295c67a75c1e30fee8c3eaa"
|
||||
integrity sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==
|
||||
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.61.1"
|
||||
"@typescript-eslint/parser" "8.61.1"
|
||||
"@typescript-eslint/typescript-estree" "8.61.1"
|
||||
"@typescript-eslint/utils" "8.61.1"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -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.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -111,9 +111,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| init.resources | object | `{}` | |
|
||||
| init.tolerations | list | `[]` | |
|
||||
| init.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to init job |
|
||||
| initImage.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| initImage.repository | string | `"apache/superset"` | |
|
||||
| initImage.tag | string | `"dockerize"` | |
|
||||
| nameOverride | string | `nil` | Provide a name to override the name of the chart |
|
||||
| nodeSelector | object | `{}` | |
|
||||
| postgresql | object | see `values.yaml` | Configuration values for the postgresql dependency. ref: https://github.com/bitnami/charts/tree/main/bitnami/postgresql |
|
||||
@@ -219,6 +216,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetNode.extraContainers | list | `[]` | Launch additional containers into supersetNode pod |
|
||||
| supersetNode.forceReload | bool | `false` | If true, forces deployment to reload on each upgrade |
|
||||
| supersetNode.initContainers | list | a container waiting for postgres | Init containers |
|
||||
| supersetNode.lifecycle | object | `{}` | Container lifecycle hooks, e.g. a preStop sleep so the Service/Ingress stops routing to the pod before gunicorn receives SIGTERM |
|
||||
| supersetNode.livenessProbe.failureThreshold | int | `3` | |
|
||||
| supersetNode.livenessProbe.httpGet.path | string | `"/health"` | |
|
||||
| supersetNode.livenessProbe.httpGet.port | string | `"http"` | |
|
||||
@@ -251,6 +249,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetNode.startupProbe.successThreshold | int | `1` | |
|
||||
| supersetNode.startupProbe.timeoutSeconds | int | `1` | |
|
||||
| supersetNode.strategy | object | `{}` | |
|
||||
| supersetNode.terminationGracePeriodSeconds | string | `nil` | Pod termination grace period (seconds). Set greater than GUNICORN_TIMEOUT so in-flight requests can drain before SIGKILL |
|
||||
| supersetNode.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to supersetNode deployments |
|
||||
| supersetWebsockets.affinity | object | `{}` | Affinity to be added to supersetWebsockets deployment |
|
||||
| supersetWebsockets.command | list | `[]` | |
|
||||
@@ -314,6 +313,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetWorker.extraContainers | list | `[]` | Launch additional containers into supersetWorker pod |
|
||||
| supersetWorker.forceReload | bool | `false` | If true, forces deployment to reload on each upgrade |
|
||||
| supersetWorker.initContainers | list | a container waiting for postgres and redis | Init container |
|
||||
| supersetWorker.lifecycle | object | `{}` | Container lifecycle hooks for the worker pod |
|
||||
| supersetWorker.livenessProbe.exec.command | list | a `celery inspect ping` command | Liveness probe command |
|
||||
| supersetWorker.livenessProbe.failureThreshold | int | `3` | |
|
||||
| supersetWorker.livenessProbe.initialDelaySeconds | int | `120` | |
|
||||
@@ -334,6 +334,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetWorker.resources | object | `{}` | Resource settings for the supersetWorker pods - these settings overwrite might existing values from the global resources object defined above. |
|
||||
| supersetWorker.startupProbe | object | `{}` | No startup/readiness probes by default since we don't really care about its startup time (it doesn't serve traffic) |
|
||||
| supersetWorker.strategy | object | `{}` | |
|
||||
| supersetWorker.terminationGracePeriodSeconds | string | `nil` | Pod termination grace period (seconds) for the worker pod so in-flight tasks can drain before SIGKILL |
|
||||
| supersetWorker.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to supersetWorker deployments |
|
||||
| tolerations | list | `[]` | |
|
||||
| topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to all deployments |
|
||||
|
||||
@@ -108,8 +108,6 @@ else:
|
||||
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
|
||||
{{- end }}
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
||||
|
||||
class CeleryConfig:
|
||||
imports = ("superset.sql_lab", )
|
||||
broker_url = CELERY_REDIS_URL
|
||||
|
||||
@@ -134,6 +134,9 @@ spec:
|
||||
{{- if .Values.supersetWorker.livenessProbe }}
|
||||
livenessProbe: {{- .Values.supersetWorker.livenessProbe | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetWorker.lifecycle }}
|
||||
lifecycle: {{- .Values.supersetWorker.lifecycle | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- if .Values.supersetWorker.resources }}
|
||||
{{- toYaml .Values.supersetWorker.resources | nindent 12 }}
|
||||
@@ -170,6 +173,9 @@ spec:
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations: {{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetWorker.terminationGracePeriodSeconds }}
|
||||
terminationGracePeriodSeconds: {{ .Values.supersetWorker.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets: {{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -144,6 +144,9 @@ spec:
|
||||
{{- if .Values.supersetNode.livenessProbe }}
|
||||
livenessProbe: {{- .Values.supersetNode.livenessProbe | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetNode.lifecycle }}
|
||||
lifecycle: {{- .Values.supersetNode.lifecycle | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- if .Values.supersetNode.resources }}
|
||||
{{- toYaml .Values.supersetNode.resources | nindent 12 }}
|
||||
@@ -180,6 +183,9 @@ spec:
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations: {{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetNode.terminationGracePeriodSeconds }}
|
||||
terminationGracePeriodSeconds: {{ .Values.supersetNode.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets: {{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -194,11 +194,6 @@ image:
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
initImage:
|
||||
repository: apache/superset
|
||||
tag: dockerize
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8088
|
||||
@@ -274,7 +269,7 @@ supersetNode:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; /usr/bin/run-server.sh"
|
||||
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; exec /usr/bin/run-server.sh"
|
||||
connections:
|
||||
# -- Change in case of bringing your own redis and then also set redis.enabled:false
|
||||
redis_host: "{{ .Release.Name }}-redis-headless"
|
||||
@@ -303,15 +298,29 @@ supersetNode:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# opening a /dev/tcp fd performs a TCP connect without sending any
|
||||
# payload (avoids postgres "incomplete startup packet" log noise);
|
||||
# no external `dockerize`, `nc`, or busybox needed. SECONDS-based
|
||||
# deadline mirrors the prior `dockerize -timeout 120s` behaviour.
|
||||
SECONDS=0
|
||||
until (exec 3<>/dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -360,6 +369,12 @@ supersetNode:
|
||||
failureThreshold: 3
|
||||
periodSeconds: 15
|
||||
successThreshold: 1
|
||||
# -- Container lifecycle hooks, e.g. a preStop sleep so the Service/Ingress
|
||||
# stops routing to the pod before gunicorn receives SIGTERM
|
||||
lifecycle: {}
|
||||
# -- Pod termination grace period (seconds). Set greater than GUNICORN_TIMEOUT so
|
||||
# in-flight requests can drain before SIGKILL
|
||||
terminationGracePeriodSeconds: ~
|
||||
# -- Resource settings for the supersetNode pods - these settings overwrite might existing values from the global resources object defined above.
|
||||
resources: {}
|
||||
# limits:
|
||||
@@ -400,22 +415,38 @@ supersetWorker:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; celery --app=superset.tasks.celery_app:app worker"
|
||||
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; exec celery --app=superset.tasks.celery_app:app worker"
|
||||
# -- If true, forces deployment to reload on each upgrade
|
||||
forceReload: false
|
||||
# -- Init container
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (exec 3<>/dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -464,6 +495,10 @@ supersetWorker:
|
||||
failureThreshold: 3
|
||||
periodSeconds: 60
|
||||
successThreshold: 1
|
||||
# -- Container lifecycle hooks for the worker pod
|
||||
lifecycle: {}
|
||||
# -- Pod termination grace period (seconds) for the worker pod so in-flight tasks can drain before SIGKILL
|
||||
terminationGracePeriodSeconds: ~
|
||||
# -- No startup/readiness probes by default since we don't really care about its startup time (it doesn't serve traffic)
|
||||
startupProbe: {}
|
||||
# -- No startup/readiness probes by default since we don't really care about its startup time (it doesn't serve traffic)
|
||||
@@ -488,22 +523,38 @@ supersetCeleryBeat:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; celery --app=superset.tasks.celery_app:app beat --pidfile /tmp/celerybeat.pid --schedule /tmp/celerybeat-schedule"
|
||||
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; exec celery --app=superset.tasks.celery_app:app beat --pidfile /tmp/celerybeat.pid --schedule /tmp/celerybeat-schedule"
|
||||
# -- If true, forces deployment to reload on each upgrade
|
||||
forceReload: false
|
||||
# -- List of init containers
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (exec 3<>/dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -594,15 +645,31 @@ supersetCeleryFlower:
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (exec 3<>/dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -764,15 +831,26 @@ init:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
until (exec 3<>/dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
|
||||
@@ -375,7 +375,6 @@ select = [
|
||||
|
||||
ignore = [
|
||||
"S101",
|
||||
"PT004", # Fixtures that don't return values - underscore prefix conflicts with pytest usage
|
||||
"PT006",
|
||||
"T201",
|
||||
"N999",
|
||||
|
||||
@@ -28,7 +28,7 @@ asyncio_mode = auto
|
||||
filterwarnings =
|
||||
ignore
|
||||
always::sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:Passing a string to Connection.execute\(\) is deprecated: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
|
||||
|
||||
@@ -315,7 +315,7 @@ pygeohash==3.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
pygments==2.20.0
|
||||
# via rich
|
||||
pyjwt==2.12.0
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -769,7 +769,7 @@ pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==5.1.2
|
||||
# via apache-superset
|
||||
pyjwt==2.12.0
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -30,7 +30,7 @@ from flask import current_app
|
||||
from flask_appbuilder import Model
|
||||
from flask_migrate import downgrade, upgrade
|
||||
from progress.bar import ChargingBar
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
|
||||
from superset import db
|
||||
@@ -154,7 +154,7 @@ def main( # noqa: C901
|
||||
|
||||
print(f"Migration goes from {down_revision} to {revision}")
|
||||
current_revision = db.engine.execute(
|
||||
"SELECT version_num FROM alembic_version"
|
||||
text("SELECT version_num FROM alembic_version")
|
||||
).scalar()
|
||||
print(f"Current version of the DB is {current_revision}")
|
||||
|
||||
|
||||
943
superset-frontend/package-lock.json
generated
943
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -119,7 +119,7 @@
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.8.0",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -192,7 +192,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -260,17 +260,17 @@
|
||||
"@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.15",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.4",
|
||||
"@storybook/addon-docs": "10.4.5",
|
||||
"@storybook/addon-links": "10.4.4",
|
||||
"@storybook/react-webpack5": "10.4.4",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.41",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-emotion": "^14.13.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -296,14 +296,14 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -323,8 +323,8 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.0",
|
||||
"eslint-plugin-storybook": "10.4.4",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.4.5",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -343,7 +343,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.69.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.4",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
@@ -355,7 +355,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.4",
|
||||
"storybook": "10.4.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
"types": "./lib/authentication/index.d.ts",
|
||||
"default": "./lib/authentication/index.js"
|
||||
},
|
||||
"./chat": {
|
||||
"types": "./lib/chat/index.d.ts",
|
||||
"default": "./lib/chat/index.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./lib/navigation/index.d.ts",
|
||||
"default": "./lib/navigation/index.js"
|
||||
},
|
||||
"./commands": {
|
||||
"types": "./lib/commands/index.d.ts",
|
||||
"default": "./lib/commands/index.js"
|
||||
|
||||
156
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
156
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Chat contribution API for Superset extensions.
|
||||
*
|
||||
* Chat is a dedicated contribution type: an extension registers
|
||||
* a chat via {@link registerChat} and the host owns where and how it is
|
||||
* mounted. The host applies singleton resolution — multiple chat extensions
|
||||
* may register, but exactly one is active at a time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { chat } from '@apache-superset/core';
|
||||
*
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* AcmeTrigger,
|
||||
* AcmePanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import type { Disposable, Event } from '../common';
|
||||
|
||||
export interface Chat {
|
||||
/** The unique identifier for the chat. */
|
||||
id: string;
|
||||
/** The display name of the chat. */
|
||||
name: string;
|
||||
/** Optional description of the chat. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type DisplayMode = 'floating' | 'panel';
|
||||
|
||||
/**
|
||||
* Registers a chat provider. Only one chat is active at a time; the most
|
||||
* recently registered chat wins. Disposing the returned Disposable unregisters
|
||||
* the chat.
|
||||
*
|
||||
* @param chat The chat descriptor (id, name).
|
||||
* @param trigger The trigger component — the collapsed bubble entry point.
|
||||
* Owns dynamic state such as unread counts.
|
||||
* @param panel The panel component, rendered in either display mode. In
|
||||
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
|
||||
* alongside the main content.
|
||||
* @returns A Disposable that unregisters the chat when disposed.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* AcmeTrigger,
|
||||
* AcmePanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerChat(
|
||||
chat: Chat,
|
||||
trigger: ComponentType,
|
||||
panel: ComponentType,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Returns the active chat descriptor, or undefined if none is registered.
|
||||
*/
|
||||
export declare function getChat(): Chat | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is registered.
|
||||
*/
|
||||
export declare const onDidRegisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Opens the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when no chat is registered or the panel is already open.
|
||||
*/
|
||||
export declare function open(): void;
|
||||
|
||||
/**
|
||||
* Closes the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when the panel is not open.
|
||||
*/
|
||||
export declare function close(): void;
|
||||
|
||||
/**
|
||||
* Returns whether the active chat's panel is currently open.
|
||||
*/
|
||||
export declare function isOpen(): boolean;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel opens. Also fired by the host's own
|
||||
* controls, not only by an extension's open() call.
|
||||
*/
|
||||
export declare const onDidOpen: Event<void>;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel closes, whether triggered by an extension
|
||||
* or by the host.
|
||||
*/
|
||||
export declare const onDidClose: Event<void>;
|
||||
|
||||
/**
|
||||
* Returns the current display mode.
|
||||
*/
|
||||
export declare function getDisplayMode(): DisplayMode;
|
||||
|
||||
/**
|
||||
* Sets the display mode. The mode is host-global and applies to whichever
|
||||
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
|
||||
* including those triggered by the host.
|
||||
*/
|
||||
export declare function setDisplayMode(displayMode: DisplayMode): void;
|
||||
|
||||
/**
|
||||
* Event fired when the display mode changes, whether triggered by an
|
||||
* extension via setDisplayMode() or by host-provided controls.
|
||||
*/
|
||||
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
|
||||
|
||||
/**
|
||||
* Event fired when the panel is resized in panel mode. Not all hosts provide
|
||||
* a resizer — do not rely on this event firing.
|
||||
*/
|
||||
export declare const onDidResizePanel: Event<{ width: number }>;
|
||||
|
||||
// TODO: client actions API — tool availability functions will be added here
|
||||
// once the client_actions SIP is finalized. The chat namespace is the
|
||||
// intended integration point between the two SIPs.
|
||||
@@ -223,8 +223,6 @@ export interface Extension {
|
||||
dependencies: string[];
|
||||
/** Human-readable description of the extension */
|
||||
description: string;
|
||||
/** List of other extensions that this extension depends on */
|
||||
extensionDependencies: string[];
|
||||
/** Unique identifier for the extension */
|
||||
id: string;
|
||||
/** Human-readable name of the extension */
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
* menus, editors, chat) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
@@ -71,7 +72,8 @@ export interface MenuContributions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
* Aggregates all contributions (commands, menus, views, editors, and chat)
|
||||
* provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
@@ -82,4 +84,10 @@ export interface Contributions {
|
||||
views: ViewContributions;
|
||||
/** List of editors. */
|
||||
editors?: Editor[];
|
||||
/**
|
||||
* The chat contributed by the extension — at most one per extension, since
|
||||
* the host applies singleton resolution and renders exactly one active
|
||||
* chat at a time.
|
||||
*/
|
||||
chat?: Chat;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
*/
|
||||
export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as chat from './chat';
|
||||
export * as commands from './commands';
|
||||
export * as editors from './editors';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
export * as navigation from './navigation';
|
||||
export * as sqlLab from './sqlLab';
|
||||
export * as views from './views';
|
||||
export * as contributions from './contributions';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Navigation namespace for Superset extensions.
|
||||
*
|
||||
* Exposes the current application surface so extensions can react to route
|
||||
* changes without polling. Entity-level context (chart, dashboard, dataset)
|
||||
* is intentionally not included here — surface-specific namespaces that
|
||||
* resolve entity payloads are introduced in later phases.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* The set of top-level application surfaces.
|
||||
*
|
||||
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
|
||||
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
|
||||
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
|
||||
* single entity is active. `'sqllab'` is the SQL editor where
|
||||
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
|
||||
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
|
||||
* the welcome surface and the fallback for any route not explicitly enumerated.
|
||||
*/
|
||||
export type Page =
|
||||
| 'dashboard'
|
||||
| 'dashboard_list'
|
||||
| 'explore'
|
||||
| 'chart_list'
|
||||
| 'sqllab'
|
||||
| 'query_history'
|
||||
| 'saved_queries'
|
||||
| 'dataset'
|
||||
| 'dataset_list'
|
||||
| 'home';
|
||||
|
||||
/**
|
||||
* Returns the current page surface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const page = navigation.getPage();
|
||||
* if (page === 'dashboard') {
|
||||
* // react to being on a dashboard surface
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getPage(): Page;
|
||||
|
||||
/**
|
||||
* Event fired whenever the user navigates to a different surface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = navigation.onDidChangePage(page => {
|
||||
* if (page === 'dashboard') {
|
||||
* // react to navigating onto a dashboard surface
|
||||
* }
|
||||
* });
|
||||
* // later:
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangePage: Event<Page>;
|
||||
@@ -30,12 +30,12 @@
|
||||
*
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
||||
* () => <ResultStatsPanel />,
|
||||
* ResultStatsPanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { ComponentType } from 'react';
|
||||
import { Disposable, Event } from '../common';
|
||||
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ export interface View {
|
||||
*
|
||||
* @param view The view descriptor (id and name).
|
||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||
* @param provider A function that returns the React element to render.
|
||||
* @param component The React component to render at that location.
|
||||
* @returns A Disposable that unregisters the view when disposed.
|
||||
*
|
||||
* @example
|
||||
@@ -66,14 +66,14 @@ export interface View {
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||
* 'sqllab.panels',
|
||||
* () => <ResultStatsPanel />,
|
||||
* ResultStatsPanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
location: string,
|
||||
provider: () => ReactElement,
|
||||
component: ComponentType,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
|
||||
@@ -132,6 +132,26 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'time_compare_full_range',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show full range for time shift'),
|
||||
default: false,
|
||||
description: t(
|
||||
'Plot each time-shifted series across its full time range instead ' +
|
||||
'of truncating it to the main series. Useful for comparing a ' +
|
||||
'partial current period (e.g. today so far) against complete ' +
|
||||
'prior periods (e.g. all of yesterday).',
|
||||
),
|
||||
visibility: ({ controls }) =>
|
||||
Boolean(controls?.time_compare?.value) &&
|
||||
(!Array.isArray(controls?.time_compare?.value) ||
|
||||
controls.time_compare.value.length > 0),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'comparison_type',
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function RadioButtonControl({
|
||||
...props
|
||||
}: RadioButtonControlProps) {
|
||||
const normalizedOptions = options.map(normalizeOption);
|
||||
const currentValue = initialValue || normalizedOptions[0].value;
|
||||
const currentValue = initialValue ?? normalizedOptions[0]?.value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -359,6 +359,51 @@ test('handles empty options array gracefully', () => {
|
||||
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('currentValue is undefined when options are empty and no value is provided', () => {
|
||||
expect(() => setup({ options: [] })).not.toThrow();
|
||||
const { container } = setup({ options: [] });
|
||||
expect(container.querySelectorAll('[id^="tab-"]').length).toBe(0);
|
||||
});
|
||||
|
||||
test('preserves falsy numeric value 0 instead of falling back to first option', () => {
|
||||
const { container } = setup({
|
||||
options: [
|
||||
[0, 'Zero'],
|
||||
[1, 'One'],
|
||||
[2, 'Two'],
|
||||
],
|
||||
value: 0,
|
||||
});
|
||||
|
||||
expect(container.querySelector('#tab-0')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
expect(container.querySelector('#tab-1')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves falsy boolean value false instead of falling back to first option', () => {
|
||||
const { container } = setup({
|
||||
options: [
|
||||
[true, 'True'],
|
||||
[false, 'False'],
|
||||
],
|
||||
value: false,
|
||||
});
|
||||
|
||||
expect(container.querySelector('#tab-true')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
expect(container.querySelector('#tab-false')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders with hovered prop', () => {
|
||||
const { container } = setup({
|
||||
label: 'Test',
|
||||
|
||||
@@ -22,21 +22,50 @@ under the License.
|
||||
[](https://www.npmjs.com/package/@superset-ui/core)
|
||||
[](https://libraries.io/npm/@superset-ui%2Fcore)
|
||||
|
||||
Description
|
||||
The core package for Apache Superset's frontend. It provides shared utilities,
|
||||
types, and abstractions used across all Superset chart plugins and UI components.
|
||||
|
||||
Key modules include:
|
||||
|
||||
- **query** — Utilities for building queries and calling the Superset API
|
||||
(including `makeApi`)
|
||||
- **number-format** — Number formatting helpers powered by d3-format
|
||||
- **time-format** — Time/date formatting helpers powered by d3-time-format
|
||||
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
|
||||
- **chart** — Base classes and types for building chart plugins
|
||||
|
||||
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
|
||||
> They now live in `@apache-superset/core`, imported from
|
||||
> `@apache-superset/core/translation`.
|
||||
|
||||
#### Example usage
|
||||
|
||||
```js
|
||||
import { xxx } from '@superset-ui/core';
|
||||
import { getNumberFormatter, makeApi } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
// Format a number
|
||||
const formatter = getNumberFormatter('.2f');
|
||||
console.log(formatter(1234.5)); // "1234.50"
|
||||
|
||||
// Translate a string
|
||||
console.log(t('Hello %s', 'world'));
|
||||
|
||||
// Call a Superset API endpoint
|
||||
const fetchDashboards = makeApi({
|
||||
method: 'GET',
|
||||
endpoint: '/api/v1/dashboard',
|
||||
});
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
`fn(args)`
|
||||
|
||||
- TBD
|
||||
|
||||
### Development
|
||||
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package including babel
|
||||
builds, jest testing, eslint, and prettier.
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package
|
||||
including babel builds, jest testing, eslint, and prettier.
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd superset-frontend
|
||||
npx jest packages/superset-ui-core
|
||||
```
|
||||
|
||||
@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
|
||||
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { mergeWith } from 'lodash';
|
||||
import { cloneDeep, mergeWith } from 'lodash';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
||||
|
||||
interface SafeMarkdownProps {
|
||||
@@ -74,7 +74,10 @@ export function transformLinkUri(uri: string): string {
|
||||
// "java\tscript:" or "java\x01script:") are ignored by browsers, so strip
|
||||
// them before comparing against the blocklist.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const scheme = url.slice(0, colon).replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
||||
const scheme = url
|
||||
.slice(0, colon)
|
||||
.replace(/[\u0000-\u0020]/g, '')
|
||||
.toLowerCase();
|
||||
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
|
||||
}
|
||||
|
||||
@@ -82,8 +85,15 @@ export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
) {
|
||||
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
// Merge into a fresh clone: mergeWith mutates its first argument, and the
|
||||
// array customizer concatenates, so merging into the shared defaultSchema
|
||||
// import would progressively widen the sanitization allowlist for every
|
||||
// SafeMarkdown instance app-wide.
|
||||
return mergeWith(
|
||||
cloneDeep(originalSchema),
|
||||
htmlSchemaOverrides,
|
||||
(objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1146,6 +1146,127 @@ test('pasting an non-existent option should not add it if allowNewOptions is fal
|
||||
expect(await findAllSelectOptions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Reference for the bug this tests: https://github.com/apache/superset/issues/32645
|
||||
// Dashboard filters with "Dynamically search all filter values" only load a
|
||||
// page of options client-side, so a pasted value outside that page used to be
|
||||
// silently dropped. allowNewOptionsOnPaste keeps such values so the filter can
|
||||
// still apply them.
|
||||
test('keeps pasted values outside loaded options when allowNewOptionsOnPaste is true', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
mode="multiple"
|
||||
allowNewOptions={false}
|
||||
allowNewOptionsOnPaste
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
// Liam is a loaded option; OutsideValue is not in the loaded page.
|
||||
getData: () => 'Liam,OutsideValue',
|
||||
},
|
||||
});
|
||||
fireEvent(input, paste);
|
||||
await waitFor(() => {
|
||||
const values = [
|
||||
...getElementsByClassName('.ant-select-selection-item'),
|
||||
].map(value => value.textContent);
|
||||
// The paste handler appends, so the loaded option resolves first.
|
||||
expect(values).toEqual(['Liam', 'OutsideValue']);
|
||||
});
|
||||
// Assert the unloaded value actually reaches the change handler (the value
|
||||
// that gets applied to the filter query), not just the rendered label.
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'OutsideValue' }),
|
||||
]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('trims whitespace around pasted comma-separated values', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
mode="multiple"
|
||||
allowNewOptions={false}
|
||||
allowNewOptionsOnPaste
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
// Note the space after the comma — it must not leak into the value.
|
||||
getData: () => 'Liam, OutsideValue',
|
||||
},
|
||||
});
|
||||
fireEvent(input, paste);
|
||||
await waitFor(() => {
|
||||
const values = [
|
||||
...getElementsByClassName('.ant-select-selection-item'),
|
||||
].map(value => value.textContent);
|
||||
expect(values).toEqual(['Liam', 'OutsideValue']);
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'OutsideValue' }),
|
||||
]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not create an empty option when pasting blank text', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
mode="multiple"
|
||||
allowNewOptions={false}
|
||||
allowNewOptionsOnPaste
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => ' ',
|
||||
},
|
||||
});
|
||||
fireEvent(input, paste);
|
||||
await waitFor(() => {
|
||||
const values = [
|
||||
...getElementsByClassName('.ant-select-selection-item'),
|
||||
].map(value => value.textContent);
|
||||
expect(values).toEqual([]);
|
||||
});
|
||||
// No empty-string value should ever reach the handler.
|
||||
onChange.mock.calls.forEach(([value]) => {
|
||||
expect(value).not.toContain('');
|
||||
});
|
||||
});
|
||||
|
||||
test('drops pasted values outside loaded options when allowNewOptionsOnPaste is false', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" allowNewOptions={false} />);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'Liam,OutsideValue',
|
||||
},
|
||||
});
|
||||
fireEvent(input, paste);
|
||||
await waitFor(() => {
|
||||
const values = [
|
||||
...getElementsByClassName('.ant-select-selection-item'),
|
||||
].map(value => value.textContent);
|
||||
expect(values).toEqual(['Liam']);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire onChange if the same value is selected in single mode', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<Select {...defaultProps} onChange={onChange} />);
|
||||
|
||||
@@ -91,6 +91,7 @@ const Select = forwardRef(
|
||||
className,
|
||||
allowClear,
|
||||
allowNewOptions = false,
|
||||
allowNewOptionsOnPaste = false,
|
||||
allowSelectAll = true,
|
||||
ariaLabel,
|
||||
autoClearSearchValue = false,
|
||||
@@ -519,7 +520,8 @@ const Select = forwardRef(
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
{t('Select all')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -536,7 +538,8 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
{t('Clear')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
@@ -690,20 +693,34 @@ const Select = forwardRef(
|
||||
}
|
||||
} else {
|
||||
const token = tokenSeparators.find(token => pastedText.includes(token));
|
||||
const array = token ? uniq(pastedText.split(token)) : [pastedText];
|
||||
const array = token
|
||||
? uniq(
|
||||
pastedText
|
||||
.split(token)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
: [pastedText.trim()].filter(Boolean);
|
||||
|
||||
const newOptions: SelectOptionsType = [];
|
||||
// When `allowNewOptionsOnPaste` is set, accept pasted values that are
|
||||
// not in the loaded options even if `allowNewOptions` is false. The
|
||||
// full option set is searched server-side and only partially loaded
|
||||
// client-side, so a pasted value can legitimately exist in the dataset
|
||||
// but fall outside the loaded page.
|
||||
const keepUnknownValues = allowNewOptions || allowNewOptionsOnPaste;
|
||||
|
||||
const values = array
|
||||
.map(item => {
|
||||
const option = getOption(item, fullSelectOptions, true);
|
||||
if (!option && allowNewOptions) {
|
||||
if (!option && keepUnknownValues) {
|
||||
const newOption = {
|
||||
label: item,
|
||||
value: item,
|
||||
isNewOption: true,
|
||||
};
|
||||
newOptions.push(newOption);
|
||||
return labelInValue ? { label: item, value: item } : item;
|
||||
}
|
||||
return getPastedTextValue(item);
|
||||
})
|
||||
|
||||
@@ -88,6 +88,18 @@ export interface BaseSelectProps extends AntdExposedProps {
|
||||
* False by default.
|
||||
* */
|
||||
allowNewOptions?: boolean;
|
||||
/**
|
||||
* Accept values pasted into the Select even when they are not part of the
|
||||
* currently loaded options and `allowNewOptions` is false. Useful for
|
||||
* selects whose full option set is searched server-side and only partially
|
||||
* loaded on the client (e.g. dashboard filters with "Dynamically search all
|
||||
* filter values"), where a pasted value can legitimately exist in the
|
||||
* dataset but fall outside the loaded page.
|
||||
* Only applies to multi-select paste; single-select paste resolves through
|
||||
* `allowNewOptions` and ignores this flag.
|
||||
* False by default.
|
||||
* */
|
||||
allowNewOptionsOnPaste?: boolean;
|
||||
/**
|
||||
* It adds the aria-label tag for accessibility standards.
|
||||
* Must be plain English and localized.
|
||||
|
||||
@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
|
||||
request: request => getInstance().request(request),
|
||||
getCSRFToken: () => getInstance().getCSRFToken(),
|
||||
getUrl: (...args) => getInstance().getUrl(...args),
|
||||
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
|
||||
get guestTokenHeaderName() {
|
||||
try {
|
||||
return getInstance().guestTokenHeaderName;
|
||||
|
||||
@@ -150,6 +150,26 @@ export default class SupersetClientClass {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request that returns a blob for file downloads.
|
||||
* Unlike postForm, this uses AJAX so errors can be caught and handled.
|
||||
* @param endpoint - API endpoint
|
||||
* @param payload - Request payload
|
||||
* @returns Promise resolving to Response with blob
|
||||
*/
|
||||
async postBlob(
|
||||
endpoint: string,
|
||||
payload: Record<string, any>,
|
||||
): Promise<Response> {
|
||||
await this.ensureAuth();
|
||||
return this.post({
|
||||
endpoint,
|
||||
postPayload: payload,
|
||||
parseMethod: 'raw',
|
||||
stringify: false,
|
||||
});
|
||||
}
|
||||
|
||||
async reAuthenticate() {
|
||||
return this.init(true);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
|
||||
| 'get'
|
||||
| 'post'
|
||||
| 'postForm'
|
||||
| 'postBlob'
|
||||
| 'put'
|
||||
| 'request'
|
||||
| 'init'
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { defaultSchema } from 'rehype-sanitize';
|
||||
import {
|
||||
getOverrideHtmlSchema,
|
||||
SafeMarkdown,
|
||||
@@ -51,6 +53,36 @@ describe('getOverrideHtmlSchema', () => {
|
||||
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
|
||||
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
|
||||
});
|
||||
|
||||
test('should not mutate the original schema', () => {
|
||||
const original = {
|
||||
attributes: { '*': ['size'] },
|
||||
tagNames: ['h1'],
|
||||
};
|
||||
getOverrideHtmlSchema(original, {
|
||||
attributes: { '*': ['src'] },
|
||||
tagNames: ['iframe'],
|
||||
});
|
||||
// The original passed in is left untouched.
|
||||
expect(original.attributes).toEqual({ '*': ['size'] });
|
||||
expect(original.tagNames).toEqual(['h1']);
|
||||
});
|
||||
|
||||
test('should not mutate the shared defaultSchema import or accumulate across calls', () => {
|
||||
const snapshot = cloneDeep(defaultSchema);
|
||||
const overrides = { tagNames: ['iframe'] };
|
||||
|
||||
const first = getOverrideHtmlSchema(defaultSchema, overrides);
|
||||
const second = getOverrideHtmlSchema(defaultSchema, overrides);
|
||||
|
||||
// The shared singleton is never modified...
|
||||
expect(defaultSchema).toEqual(snapshot);
|
||||
// ...and repeated calls do not accumulate the override (no growing arrays).
|
||||
expect(first.tagNames).toEqual(second.tagNames);
|
||||
expect(
|
||||
(second.tagNames ?? []).filter(name => name === 'iframe'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformLinkUri', () => {
|
||||
|
||||
@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
|
||||
getUrl: (...args: unknown[]) => string;
|
||||
};
|
||||
|
||||
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||
expect(typeof SupersetClient.configure).toBe('function');
|
||||
expect(typeof SupersetClient.init).toBe('function');
|
||||
expect(typeof SupersetClient.get).toBe('function');
|
||||
expect(typeof SupersetClient.post).toBe('function');
|
||||
expect(typeof SupersetClient.postForm).toBe('function');
|
||||
expect(typeof SupersetClient.postBlob).toBe('function');
|
||||
expect(typeof SupersetClient.delete).toBe('function');
|
||||
expect(typeof SupersetClient.put).toBe('function');
|
||||
expect(typeof SupersetClient.request).toBe('function');
|
||||
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
|
||||
expect(typeof SupersetClient.reAuthenticate).toBe('function');
|
||||
});
|
||||
|
||||
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||
expect(SupersetClient.init).toThrow();
|
||||
expect(SupersetClient.get).toThrow();
|
||||
expect(SupersetClient.post).toThrow();
|
||||
expect(SupersetClient.postForm).toThrow();
|
||||
expect(SupersetClient.postBlob).toThrow();
|
||||
expect(SupersetClient.delete).toThrow();
|
||||
expect(SupersetClient.put).toThrow();
|
||||
expect(SupersetClient.request).toThrow();
|
||||
|
||||
@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
|
||||
expect(authSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.postBlob()', () => {
|
||||
const protocol = 'https:';
|
||||
const host = 'host';
|
||||
const mockPostBlobEndpoint = '/api/v1/chart/data';
|
||||
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
|
||||
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
|
||||
|
||||
let authSpy: jest.SpyInstance;
|
||||
let client: SupersetClientClass;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
|
||||
|
||||
client = new SupersetClientClass({ protocol, host });
|
||||
await client.init();
|
||||
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
|
||||
const mockResponse = new Response('csv data', { status: 200 });
|
||||
const postSpy = jest
|
||||
.spyOn(client, 'post')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await client.postBlob(
|
||||
mockPostBlobEndpoint,
|
||||
postBlobPayload,
|
||||
);
|
||||
|
||||
expect(authSpy).toHaveBeenCalledTimes(1);
|
||||
expect(postSpy).toHaveBeenCalledWith({
|
||||
endpoint: mockPostBlobEndpoint,
|
||||
postPayload: postBlobPayload,
|
||||
parseMethod: 'raw',
|
||||
stringify: false,
|
||||
});
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
test('passes payload in request body', async () => {
|
||||
fetchMock.post(mockPostBlobUrl, {
|
||||
status: 200,
|
||||
body: 'csv data',
|
||||
});
|
||||
|
||||
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
|
||||
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
|
||||
.options as CallApi;
|
||||
const formData = fetchRequest.body as FormData;
|
||||
|
||||
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
|
||||
});
|
||||
|
||||
test('rejects when response is not ok', async () => {
|
||||
fetchMock.post(mockPostBlobUrl, {
|
||||
status: 413,
|
||||
body: 'Payload Too Large',
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
|
||||
).rejects.toMatchObject({ status: 413 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,8 +97,11 @@ testWithAssets(
|
||||
});
|
||||
|
||||
// At least one list item should contain a DD.MM.YYYY formatted date.
|
||||
await expect(panel.locator('li').first()).toHaveText(/\d{2}\.\d{2}\.\d{4}/, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(panel.locator('li').first()).toHaveText(
|
||||
/\d{2}\.\d{2}\.\d{4}/,
|
||||
{
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -182,10 +182,7 @@ testWithAssets(
|
||||
// Now track POST /api/v1/chart/data requests around Clear All
|
||||
const postsAfterClearAll: string[] = [];
|
||||
const handler = (req: any) => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
|
||||
postsAfterClearAll.push(req.url());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,7 +109,12 @@ testWithAssets(
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: { chartId, width: 8, height: 60, sliceName: 'mixed_filter_repro' },
|
||||
meta: {
|
||||
chartId,
|
||||
width: 8,
|
||||
height: 60,
|
||||
sliceName: 'mixed_filter_repro',
|
||||
},
|
||||
},
|
||||
};
|
||||
const jsonMetadata = {
|
||||
@@ -130,9 +135,7 @@ testWithAssets(
|
||||
defaultDataMask: {
|
||||
filterState: { value: [FILTER_VALUE] },
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] },
|
||||
],
|
||||
filters: [{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] }],
|
||||
},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
@@ -158,15 +161,14 @@ testWithAssets(
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, { dashboards: [dashboardId] });
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
|
||||
// Capture the Mixed chart's data request (the one with two queries).
|
||||
const twoQueryPayloads: any[] = [];
|
||||
page.on('request', req => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
|
||||
try {
|
||||
const body = req.postDataJSON();
|
||||
if (body?.queries?.length === 2) {
|
||||
|
||||
@@ -50,7 +50,10 @@ import {
|
||||
getGuestToken,
|
||||
} from '../../helpers/api/embedded';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard, apiDeleteDashboard } from '../../helpers/api/dashboard';
|
||||
import {
|
||||
apiPostDashboard,
|
||||
apiDeleteDashboard,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { apiDeleteChart } from '../../helpers/api/chart';
|
||||
import { EmbeddedPage } from '../../pages/EmbeddedPage';
|
||||
import { EMBEDDED } from '../../utils/constants';
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ControlPanelConfig,
|
||||
D3_FORMAT_DOCS,
|
||||
D3_TIME_FORMAT_OPTIONS,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
getStandardizedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
@@ -145,7 +146,7 @@ const config: ControlPanelConfig = {
|
||||
freeForm: true,
|
||||
label: t('Time Format'),
|
||||
renderTrigger: true,
|
||||
default: 'smart_date',
|
||||
default: DEFAULT_TIME_FORMAT,
|
||||
choices: D3_TIME_FORMAT_OPTIONS,
|
||||
description: D3_FORMAT_DOCS,
|
||||
},
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
import d3 from 'd3';
|
||||
import { extent as d3Extent } from 'd3-array';
|
||||
import {
|
||||
ValueFormatter,
|
||||
getNumberFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
BinaryQueryObjectFilterClause,
|
||||
CategoricalColorNamespace,
|
||||
ContextMenuFilters,
|
||||
DataMask,
|
||||
ValueFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import countries, { countryOptions } from './countries';
|
||||
|
||||
@@ -65,9 +67,28 @@ interface CountryMapProps {
|
||||
formatter: ValueFormatter;
|
||||
colorScheme: string;
|
||||
sliceId: number;
|
||||
onContextMenu?: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
data: ContextMenuFilters,
|
||||
) => void;
|
||||
emitCrossFilters?: boolean;
|
||||
setDataMask?: (dataMask: DataMask) => void;
|
||||
filterState?: {
|
||||
selectedValues?: string[];
|
||||
extraFormData?: {
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
};
|
||||
};
|
||||
entity?: string;
|
||||
}
|
||||
|
||||
const maps: Record<string, GeoData> = {};
|
||||
// Store zoom state per chart instance using element as key to enable garbage collection
|
||||
const zoomStates = new WeakMap<
|
||||
HTMLElement,
|
||||
{ scale: number; translate: [number, number] }
|
||||
>();
|
||||
|
||||
function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
const {
|
||||
@@ -75,10 +96,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
width,
|
||||
height,
|
||||
country,
|
||||
entity,
|
||||
linearColorScheme,
|
||||
formatter,
|
||||
colorScheme,
|
||||
sliceId,
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
} = props;
|
||||
|
||||
const container = element;
|
||||
@@ -99,7 +125,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
? colorScale(d.country_id, sliceId)
|
||||
: (linearColorScale(d.metric) ?? '');
|
||||
});
|
||||
const colorFn = (d: GeoFeature) => colorMap[d.properties.ISO] || 'none';
|
||||
|
||||
const colorFn = (feature: GeoFeature): string => {
|
||||
if (!feature?.properties) return '#d9d9d9';
|
||||
const iso = feature.properties.ISO;
|
||||
return colorMap[iso] || '#d9d9d9';
|
||||
};
|
||||
|
||||
// Check if dashboard is in edit mode
|
||||
const isEditMode = container.closest('.dashboard--editing') !== null;
|
||||
|
||||
const path = d3.geo.path();
|
||||
const div = d3.select(container);
|
||||
@@ -112,6 +146,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
// Only set grab cursor if not in edit mode
|
||||
if (!isEditMode) {
|
||||
svg.style('cursor', 'grab');
|
||||
}
|
||||
const backgroundRect = svg
|
||||
.append('rect')
|
||||
.attr('class', 'background')
|
||||
@@ -119,39 +158,64 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.attr('height', height);
|
||||
const g = svg.append('g');
|
||||
const mapLayer = g.append('g').classed('map-layer', true);
|
||||
// Add hover popup for tooltip
|
||||
const hoverPopup = div.append('div').attr('class', 'hover-popup');
|
||||
|
||||
let centered: GeoFeature | null;
|
||||
// Track mouse position to distinguish clicks from drags
|
||||
let mousedownPos: { x: number; y: number } | null = null;
|
||||
|
||||
const clicked = function clicked(d: GeoFeature) {
|
||||
const hasCenter = d && centered !== d;
|
||||
let x: number;
|
||||
let y: number;
|
||||
let k: number;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
// Cross-filter support
|
||||
const getCrossFilterDataMask = (
|
||||
source: GeoFeature,
|
||||
): { dataMask: DataMask; isCurrentValueSelected: boolean } | undefined => {
|
||||
if (!entity) return undefined;
|
||||
|
||||
if (hasCenter) {
|
||||
const centroid = path.centroid(d);
|
||||
[x, y] = centroid;
|
||||
k = 4;
|
||||
centered = d;
|
||||
} else {
|
||||
x = halfWidth;
|
||||
y = halfHeight;
|
||||
k = 1;
|
||||
centered = null;
|
||||
}
|
||||
const selected = filterState?.selectedValues || [];
|
||||
const iso = source?.properties?.ISO;
|
||||
if (!iso) return undefined;
|
||||
|
||||
g.transition()
|
||||
.duration(750)
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`,
|
||||
);
|
||||
const isSelected = selected.includes(iso);
|
||||
const values = isSelected ? [] : [iso];
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: values.length
|
||||
? [{ col: entity, op: 'IN', val: values }]
|
||||
: [],
|
||||
},
|
||||
filterState: {
|
||||
value: values.length ? values : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: isSelected,
|
||||
};
|
||||
};
|
||||
|
||||
backgroundRect.on('click', clicked);
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (feature: GeoFeature): void => {
|
||||
const pointerEvent = d3.event;
|
||||
|
||||
if (typeof onContextMenu === 'function') {
|
||||
pointerEvent?.preventDefault();
|
||||
}
|
||||
|
||||
const iso = feature?.properties?.ISO;
|
||||
if (!iso || typeof onContextMenu !== 'function' || !entity) return;
|
||||
|
||||
const drillVal = iso;
|
||||
const drillToDetailFilters = [
|
||||
{ col: entity, op: '==', val: drillVal, formattedVal: drillVal },
|
||||
];
|
||||
const drillByFilters = [{ col: entity, op: '==', val: drillVal }];
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
crossFilter: getCrossFilterDataMask(feature),
|
||||
drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' },
|
||||
});
|
||||
};
|
||||
|
||||
const getNameOfRegion = function getNameOfRegion(
|
||||
feature: GeoFeature,
|
||||
@@ -165,7 +229,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
return '';
|
||||
};
|
||||
|
||||
const updatePopupPosition = () => {
|
||||
const updatePopupPosition = (): void => {
|
||||
const svgHeight = svg.node().getBoundingClientRect().height;
|
||||
const [x, y] = d3.mouse(svg.node());
|
||||
hoverPopup
|
||||
@@ -175,34 +239,135 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.classed('popup-at-bottom', y > (svgHeight * 2) / 3);
|
||||
};
|
||||
|
||||
const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) {
|
||||
const mouseenter = function mouseenter(
|
||||
this: SVGPathElement,
|
||||
d: GeoFeature,
|
||||
): void {
|
||||
// Darken color
|
||||
let c: string = colorFn(d);
|
||||
if (c !== 'none') {
|
||||
if (c) {
|
||||
c = d3.rgb(c).darker().toString();
|
||||
}
|
||||
d3.select(this).style('fill', c);
|
||||
|
||||
// Display information popup
|
||||
const result = data.filter(
|
||||
region => region.country_id === d.properties.ISO,
|
||||
);
|
||||
|
||||
hoverPopup.style('display', 'block').html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
const result = data.filter(r => r.country_id === d?.properties?.ISO);
|
||||
const regionName = escapeHtml(getNameOfRegion(d));
|
||||
const metricValue =
|
||||
result.length > 0 ? escapeHtml(String(formatter(result[0].metric))) : '';
|
||||
hoverPopup
|
||||
.style('display', 'block')
|
||||
.html(`<div><strong>${regionName}</strong><br>${metricValue}</div>`);
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mousemove = function mousemove() {
|
||||
// Mouse move handler to update tooltip position
|
||||
const mousemove = function mousemove(): void {
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mouseout = function mouseout(this: SVGPathElement) {
|
||||
d3.select(this).style('fill', colorFn);
|
||||
const mouseout = function mouseout(this: SVGPathElement): void {
|
||||
d3.select(this).style('fill', (d: GeoFeature) => colorFn(d));
|
||||
hoverPopup.style('display', 'none');
|
||||
};
|
||||
|
||||
function drawMap(mapData: GeoData) {
|
||||
// Only enable zoom if not in edit mode
|
||||
if (!isEditMode) {
|
||||
// Zoom with panning bounds
|
||||
const zoom = d3.behavior
|
||||
.zoom()
|
||||
.scaleExtent([1, 4])
|
||||
.on('zoomstart', () => {
|
||||
svg.style('cursor', 'grabbing');
|
||||
})
|
||||
.on('zoom', () => {
|
||||
const { translate, scale } = d3.event;
|
||||
let [tx, ty] = translate;
|
||||
|
||||
const scaledW = width * scale;
|
||||
const scaledH = height * scale;
|
||||
const minX = Math.min(0, width - scaledW);
|
||||
const maxX = 0;
|
||||
const minY = Math.min(0, height - scaledH);
|
||||
const maxY = 0;
|
||||
|
||||
tx = Math.max(Math.min(tx, maxX), minX);
|
||||
ty = Math.max(Math.min(ty, maxY), minY);
|
||||
|
||||
// Sync D3's internal translate state with the clamped values so the
|
||||
// next wheel/zoom event starts from the constrained position rather
|
||||
// than the unclamped one (otherwise the view jumps).
|
||||
zoom.translate([tx, ty]);
|
||||
|
||||
g.attr('transform', `translate(${tx}, ${ty}) scale(${scale})`);
|
||||
const prev = zoomStates.get(element);
|
||||
const changed =
|
||||
!prev ||
|
||||
prev.scale !== scale ||
|
||||
prev.translate[0] !== tx ||
|
||||
prev.translate[1] !== ty;
|
||||
if (changed) {
|
||||
zoomStates.set(element, { scale, translate: [tx, ty] });
|
||||
}
|
||||
})
|
||||
.on('zoomend', () => {
|
||||
svg.style('cursor', 'grab');
|
||||
});
|
||||
|
||||
d3.select(svg.node()).call(zoom);
|
||||
|
||||
// Restore previous zoom state if it exists
|
||||
const savedZoom = zoomStates.get(element);
|
||||
if (savedZoom) {
|
||||
const { scale, translate } = savedZoom;
|
||||
zoom.scale(scale).translate(translate);
|
||||
g.attr(
|
||||
'transform',
|
||||
`translate(${translate[0]}, ${translate[1]}) scale(${scale})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Visual highlighting for selected regions
|
||||
function highlightSelectedRegion(
|
||||
selectedValues: string[] | null = null,
|
||||
): void {
|
||||
const selected = selectedValues || filterState?.selectedValues || [];
|
||||
|
||||
mapLayer
|
||||
.selectAll('path.region')
|
||||
.style('fill-opacity', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.length === 0 || selected.includes(iso) ? 1 : 0.3;
|
||||
})
|
||||
.style('stroke', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.includes(iso) ? '#222' : null;
|
||||
})
|
||||
.style('stroke-width', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.includes(iso) ? '1.5px' : '0.5px';
|
||||
});
|
||||
}
|
||||
|
||||
// Click handler for cross-filters
|
||||
const handleClick = (feature: GeoFeature): void => {
|
||||
if (!entity || !emitCrossFilters || typeof setDataMask !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = getCrossFilterDataMask(feature);
|
||||
if (!result) return;
|
||||
|
||||
const { dataMask, isCurrentValueSelected } = result;
|
||||
setDataMask(dataMask);
|
||||
|
||||
const iso = feature?.properties?.ISO;
|
||||
const newSelection = isCurrentValueSelected || !iso ? [] : [iso];
|
||||
highlightSelectedRegion(newSelection);
|
||||
};
|
||||
|
||||
function drawMap(mapData: GeoData): void {
|
||||
const { features } = mapData;
|
||||
const center = d3.geo.centroid(mapData);
|
||||
const scale = 100;
|
||||
@@ -213,13 +378,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.translate([width / 2, height / 2]);
|
||||
path.projection(projection);
|
||||
|
||||
// Compute scale that fits container.
|
||||
const bounds = path.bounds(mapData);
|
||||
const hscale = (scale * width) / (bounds[1][0] - bounds[0][0]);
|
||||
const vscale = (scale * height) / (bounds[1][1] - bounds[0][1]);
|
||||
const newScale = hscale < vscale ? hscale : vscale;
|
||||
const newScale = Math.min(hscale, vscale);
|
||||
|
||||
// Compute bounds and offset using the updated scale.
|
||||
projection.scale(newScale);
|
||||
const newBounds = path.bounds(mapData);
|
||||
projection.translate([
|
||||
@@ -227,20 +390,45 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
height - (newBounds[0][1] + newBounds[1][1]) / 2,
|
||||
]);
|
||||
|
||||
// Draw each province as a path
|
||||
mapLayer
|
||||
.selectAll('path')
|
||||
.data(features)
|
||||
const sel = mapLayer.selectAll('path.region').data(features);
|
||||
|
||||
sel
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('d', path)
|
||||
.attr('class', 'region')
|
||||
.attr('vector-effect', 'non-scaling-stroke')
|
||||
.attr('vector-effect', 'non-scaling-stroke');
|
||||
|
||||
// Apply attributes and event handlers to all elements (enter + update)
|
||||
mapLayer
|
||||
.selectAll('path.region')
|
||||
.attr('d', path)
|
||||
.style('fill', colorFn)
|
||||
.on('mouseenter', mouseenter)
|
||||
.on('mousemove', mousemove)
|
||||
.on('mouseout', mouseout)
|
||||
.on('click', clicked);
|
||||
.on('contextmenu', handleContextMenu)
|
||||
.on('mousedown', function mousedown() {
|
||||
const pos = d3.mouse(svg.node());
|
||||
mousedownPos = { x: pos[0], y: pos[1] };
|
||||
})
|
||||
.on('click', function click(feature: GeoFeature) {
|
||||
if (mousedownPos) {
|
||||
const pos = d3.mouse(svg.node());
|
||||
const dx = Math.abs(pos[0] - mousedownPos.x);
|
||||
const dy = Math.abs(pos[1] - mousedownPos.y);
|
||||
const dragThreshold = 5;
|
||||
|
||||
if (dx < dragThreshold && dy < dragThreshold) {
|
||||
handleClick(feature);
|
||||
}
|
||||
|
||||
mousedownPos = null;
|
||||
}
|
||||
});
|
||||
|
||||
sel.exit().remove();
|
||||
|
||||
highlightSelectedRegion();
|
||||
}
|
||||
|
||||
const map = maps[country];
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import { ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import exampleUsa from './images/exampleUsa.jpg';
|
||||
import exampleUsaDark from './images/exampleUsa-dark.jpg';
|
||||
@@ -49,6 +49,11 @@ const metadata = new ChartMetadata({
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
behaviors: [
|
||||
Behavior.InteractiveChart,
|
||||
Behavior.DrillToDetail,
|
||||
Behavior.DrillBy,
|
||||
],
|
||||
});
|
||||
|
||||
export default class CountryMapChartPlugin extends ChartPlugin {
|
||||
|
||||
@@ -19,8 +19,18 @@
|
||||
import { ChartProps, getValueFormatter } from '@superset-ui/core';
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, queriesData, datasource } = chartProps;
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
formData,
|
||||
queriesData,
|
||||
datasource,
|
||||
hooks = {},
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
} = chartProps;
|
||||
const {
|
||||
entity,
|
||||
linearColorScheme,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
@@ -49,6 +59,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
detectedCurrency,
|
||||
);
|
||||
|
||||
const { onContextMenu, setDataMask } = hooks;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@@ -59,5 +71,10 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
colorScheme,
|
||||
sliceId,
|
||||
formatter,
|
||||
entity,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
emitCrossFilters,
|
||||
filterState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,10 +133,11 @@ describe('CountryMap (legacy d3)', () => {
|
||||
expect(popup!).toHaveStyle({ display: 'none' });
|
||||
});
|
||||
|
||||
test('shows tooltip on mouseenter/mousemove/mouseout', async () => {
|
||||
test('emits a cross-filter data mask when a region is clicked', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
@@ -147,19 +148,101 @@ describe('CountryMap (legacy d3)', () => {
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMask}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
expect(region).not.toBeNull();
|
||||
|
||||
const popup = document.querySelector('.hover-popup');
|
||||
expect(popup).not.toBeNull();
|
||||
// A click is only treated as a selection when it follows a mousedown
|
||||
// without dragging beyond the threshold (d3.mouse is mocked to a fixed
|
||||
// position, so the down/up positions match).
|
||||
fireEvent.mouseDown(region!);
|
||||
fireEvent.click(region!);
|
||||
|
||||
fireEvent.mouseEnter(region!);
|
||||
expect(popup!).toHaveStyle({ display: 'block' });
|
||||
expect(setDataMask).toHaveBeenCalledTimes(1);
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {
|
||||
filters: [{ col: 'country_code', op: 'IN', val: ['CAN'] }],
|
||||
},
|
||||
filterState: expect.objectContaining({ value: ['CAN'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.mouseOut(region!);
|
||||
expect(popup!).toHaveStyle({ display: 'none' });
|
||||
test('does not emit a cross-filter when emitCrossFilters is disabled', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
width={500}
|
||||
height={300}
|
||||
data={[{ country_id: 'CAN', metric: 100 }]}
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
emitCrossFilters={false}
|
||||
setDataMask={setDataMask}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
fireEvent.mouseDown(region!);
|
||||
fireEvent.click(region!);
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opens the context menu with drill-by keyed on the entity control', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const onContextMenu = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
width={500}
|
||||
height={300}
|
||||
data={[{ country_id: 'CAN', metric: 100 }]}
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
onContextMenu={onContextMenu}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
expect(region).not.toBeNull();
|
||||
|
||||
fireEvent.contextMenu(region!, { clientX: 123, clientY: 45 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledTimes(1);
|
||||
const [[clientX, clientY, payload]] = onContextMenu.mock.calls;
|
||||
expect(clientX).toBe(123);
|
||||
expect(clientY).toBe(45);
|
||||
expect(payload.drillToDetail).toEqual([
|
||||
{ col: 'country_code', op: '==', val: 'CAN', formattedVal: 'CAN' },
|
||||
]);
|
||||
// groupbyFieldName must be the form-data control key ('entity'), not the
|
||||
// selected column value ('country_code'), so DrillByModal can map the
|
||||
// selection back to the chart control.
|
||||
expect(payload.drillBy).toEqual({
|
||||
filters: [{ col: 'country_code', op: '==', val: 'CAN' }],
|
||||
groupbyFieldName: 'entity',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import transformProps from '../src/transformProps';
|
||||
|
||||
const onContextMenu = jest.fn();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
const createProps = (formDataOverrides = {}, chartPropsOverrides = {}) =>
|
||||
({
|
||||
width: 800,
|
||||
height: 600,
|
||||
formData: {
|
||||
entity: 'country_code',
|
||||
linearColorScheme: 'bnbColors',
|
||||
numberFormat: '.2f',
|
||||
selectCountry: 'France',
|
||||
colorScheme: '',
|
||||
sliceId: 1,
|
||||
metric: 'count',
|
||||
...formDataOverrides,
|
||||
},
|
||||
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
|
||||
datasource: { currencyFormats: {}, columnFormats: {} },
|
||||
hooks: { onContextMenu, setDataMask },
|
||||
filterState: { selectedValues: ['FRA'] },
|
||||
emitCrossFilters: true,
|
||||
...chartPropsOverrides,
|
||||
}) as unknown as ChartProps;
|
||||
|
||||
test('forwards cross-filter hooks and state to the chart', () => {
|
||||
const transformed = transformProps(createProps());
|
||||
|
||||
expect(transformed).toMatchObject({
|
||||
width: 800,
|
||||
height: 600,
|
||||
entity: 'country_code',
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
emitCrossFilters: true,
|
||||
filterState: { selectedValues: ['FRA'] },
|
||||
data: [{ country_id: 'FRA', metric: 10 }],
|
||||
});
|
||||
});
|
||||
|
||||
test('lowercases the selected country for map lookup', () => {
|
||||
const transformed = transformProps(createProps());
|
||||
expect(transformed.country).toBe('france');
|
||||
});
|
||||
|
||||
test('passes a null country when none is selected', () => {
|
||||
const transformed = transformProps(createProps({ selectCountry: undefined }));
|
||||
expect(transformed.country).toBeNull();
|
||||
});
|
||||
|
||||
test('defaults hooks to an empty object when none are provided', () => {
|
||||
const transformed = transformProps(createProps({}, { hooks: undefined }));
|
||||
expect(transformed.onContextMenu).toBeUndefined();
|
||||
expect(transformed.setDataMask).toBeUndefined();
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
D3_FORMAT_DOCS,
|
||||
D3_FORMAT_OPTIONS,
|
||||
D3_TIME_FORMAT_OPTIONS,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
getStandardizedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import OptionDescription from './OptionDescription';
|
||||
@@ -154,7 +155,7 @@ const config: ControlPanelConfig = {
|
||||
freeForm: true,
|
||||
label: t('Date Time Format'),
|
||||
renderTrigger: true,
|
||||
default: 'smart_date',
|
||||
default: DEFAULT_TIME_FORMAT,
|
||||
choices: D3_TIME_FORMAT_OPTIONS,
|
||||
description: D3_FORMAT_DOCS,
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
D3_TIME_FORMAT_OPTIONS,
|
||||
sections,
|
||||
getStandardizedControls,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
@@ -78,7 +79,7 @@ const config: ControlPanelConfig = {
|
||||
freeForm: true,
|
||||
label: t('Date Time Format'),
|
||||
renderTrigger: true,
|
||||
default: 'smart_date',
|
||||
default: DEFAULT_TIME_FORMAT,
|
||||
choices: D3_TIME_FORMAT_OPTIONS,
|
||||
description: D3_FORMAT_DOCS,
|
||||
},
|
||||
|
||||
@@ -164,7 +164,8 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
processedData = filteredData.map(d => ({
|
||||
...d,
|
||||
radius: radiusScale(Math.sqrt(d.m2)),
|
||||
fillColor: d.m1 != null ? colorFn(d.m1) ?? theme.colorBorder : theme.colorBorder,
|
||||
fillColor:
|
||||
d.m1 != null ? (colorFn(d.m1) ?? theme.colorBorder) : theme.colorBorder,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -304,36 +305,16 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
key: JSON.stringify,
|
||||
},
|
||||
done: datamap => {
|
||||
// Hover highlighting and its reset are handled entirely by Datamaps'
|
||||
// built-in highlightOnHover, which saves each country's original fill on
|
||||
// mouseover and restores it on mouseout. Adding our own mouseover/mouseout
|
||||
// fill handlers here creates a second, competing restore path whose
|
||||
// execution order is browser-timing-dependent, which left the highlight
|
||||
// stuck on Chrome/Edge (see #37761).
|
||||
datamap.svg
|
||||
.selectAll('.datamaps-subunit')
|
||||
.on('contextmenu', handleContextMenu)
|
||||
.on('click', handleClick)
|
||||
// Use namespaced events to avoid overriding Datamaps' default tooltip handlers
|
||||
.on('mouseover.fillPreserve', function onMouseOver() {
|
||||
if (inContextMenu) {
|
||||
return;
|
||||
}
|
||||
const element = d3.select(this);
|
||||
const classes = element.attr('class') || '';
|
||||
const countryId = classes.split(' ')[1];
|
||||
const countryData = mapData[countryId];
|
||||
const originalFill =
|
||||
(countryData && countryData.fillColor) || theme.colorBorder;
|
||||
// Store original fill color for restoration
|
||||
element.attr('data-original-fill', originalFill);
|
||||
})
|
||||
.on('mouseout.fillPreserve', function onMouseOut() {
|
||||
if (inContextMenu) {
|
||||
return;
|
||||
}
|
||||
const element = d3.select(this);
|
||||
const originalFill = element.attr('data-original-fill');
|
||||
// Restore the original fill color (data-based or default no-data color)
|
||||
if (originalFill) {
|
||||
element.style('fill', originalFill);
|
||||
element.attr('data-original-fill', null);
|
||||
}
|
||||
});
|
||||
.on('click', handleClick);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,15 +58,6 @@ interface WorldMapProps {
|
||||
formatter: ValueFormatter;
|
||||
}
|
||||
|
||||
type MouseEventHandler = (this: HTMLElement) => void;
|
||||
|
||||
interface MockD3Selection {
|
||||
attr: jest.Mock;
|
||||
style: jest.Mock;
|
||||
classed: jest.Mock;
|
||||
selectAll: jest.Mock;
|
||||
}
|
||||
|
||||
// Mock Datamap
|
||||
const mockBubbles = jest.fn();
|
||||
const mockUpdateChoropleth = jest.fn();
|
||||
@@ -157,244 +148,36 @@ afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
test('sets up mouseover and mouseout handlers on countries', () => {
|
||||
test('relies on Datamaps highlightOnHover without adding conflicting fill handlers', () => {
|
||||
// Regression test for #37761. The hover highlight got stuck on Chrome/Edge
|
||||
// because hand-written mouseover/mouseout handlers competed with Datamaps'
|
||||
// built-in highlightOnHover restore path, and the winning path was
|
||||
// browser-timing-dependent. The chart should rely on the single built-in
|
||||
// path and register no custom fill-restoring hover handlers on the countries.
|
||||
WorldMap(container, baseProps);
|
||||
|
||||
expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
|
||||
const onCalls = mockSvg.on.mock.calls;
|
||||
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(geographyConfig?.highlightOnHover).toBe(true);
|
||||
|
||||
// Find mouseover and mouseout handler registrations (namespaced events)
|
||||
const hasMouseover = onCalls.some(
|
||||
call => call[0] === 'mouseover.fillPreserve',
|
||||
const hoverHandlers = mockSvg.on.mock.calls.filter((call: [string]) =>
|
||||
/^mouse(over|out)/.test(call[0]),
|
||||
);
|
||||
const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve');
|
||||
|
||||
expect(hasMouseover).toBe(true);
|
||||
expect(hasMouseout).toBe(true);
|
||||
expect(hoverHandlers).toEqual([]);
|
||||
});
|
||||
|
||||
test('stores original fill color on mouseover', () => {
|
||||
// Create a mock DOM element with d3 selection capabilities
|
||||
const mockElement = document.createElement('path');
|
||||
mockElement.setAttribute('class', 'datamaps-subunit USA');
|
||||
mockElement.style.fill = 'rgb(100, 150, 200)';
|
||||
container.appendChild(mockElement);
|
||||
test('disables Datamaps highlightOnHover while the context menu is open', () => {
|
||||
// Companion to the regression guard above: when the context menu is open we
|
||||
// pass highlightOnHover: false so hover highlighting is suppressed at init.
|
||||
WorldMap(container, { ...baseProps, inContextMenu: true });
|
||||
|
||||
let mouseoverHandler: MouseEventHandler | null = null;
|
||||
|
||||
// Mock d3.select to return the mock element
|
||||
const mockD3Selection: MockD3Selection = {
|
||||
attr: jest.fn((attrName: string, value?: string) => {
|
||||
if (value !== undefined) {
|
||||
mockElement.setAttribute(attrName, value);
|
||||
} else {
|
||||
return mockElement.getAttribute(attrName);
|
||||
}
|
||||
return mockD3Selection;
|
||||
}),
|
||||
style: jest.fn((styleName: string, value?: string) => {
|
||||
if (value !== undefined) {
|
||||
mockElement.style[styleName as any] = value;
|
||||
} else {
|
||||
return mockElement.style[styleName as any];
|
||||
}
|
||||
return mockD3Selection;
|
||||
}),
|
||||
classed: jest.fn().mockReturnThis(),
|
||||
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
|
||||
|
||||
// Capture the mouseover handler (namespaced event)
|
||||
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
|
||||
if (event === 'mouseover.fillPreserve') {
|
||||
mouseoverHandler = handler;
|
||||
}
|
||||
return mockSvg;
|
||||
});
|
||||
|
||||
WorldMap(container, baseProps);
|
||||
|
||||
// Simulate mouseover
|
||||
if (mouseoverHandler) {
|
||||
(mouseoverHandler as MouseEventHandler).call(mockElement);
|
||||
}
|
||||
|
||||
// Verify that data-original-fill attribute was set
|
||||
expect(mockD3Selection.attr).toHaveBeenCalledWith(
|
||||
'data-original-fill',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
test('restores original fill color on mouseout for country with data', () => {
|
||||
const mockElement = document.createElement('path');
|
||||
mockElement.setAttribute('class', 'datamaps-subunit USA');
|
||||
mockElement.style.fill = 'rgb(100, 150, 200)';
|
||||
mockElement.setAttribute('data-original-fill', 'rgb(100, 150, 200)');
|
||||
container.appendChild(mockElement);
|
||||
|
||||
let mouseoutHandler: MouseEventHandler | null = null;
|
||||
|
||||
const mockD3Selection: MockD3Selection = {
|
||||
attr: jest.fn((attrName: string, value?: string | null) => {
|
||||
if (value !== undefined) {
|
||||
if (value === null) {
|
||||
mockElement.removeAttribute(attrName);
|
||||
} else {
|
||||
mockElement.setAttribute(attrName, value);
|
||||
}
|
||||
return mockD3Selection;
|
||||
}
|
||||
return mockElement.getAttribute(attrName);
|
||||
}),
|
||||
style: jest.fn((styleName: string, value?: string) => {
|
||||
if (value !== undefined) {
|
||||
mockElement.style[styleName as any] = value;
|
||||
}
|
||||
return mockElement.style[styleName as any] || mockD3Selection;
|
||||
}),
|
||||
classed: jest.fn().mockReturnThis(),
|
||||
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
|
||||
|
||||
// Capture the mouseout handler (namespaced event)
|
||||
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
|
||||
if (event === 'mouseout.fillPreserve') {
|
||||
mouseoutHandler = handler;
|
||||
}
|
||||
return mockSvg;
|
||||
});
|
||||
|
||||
WorldMap(container, baseProps);
|
||||
|
||||
// Simulate mouseout
|
||||
if (mouseoutHandler) {
|
||||
(mouseoutHandler as MouseEventHandler).call(mockElement);
|
||||
}
|
||||
|
||||
// Verify that original fill was restored
|
||||
expect(mockD3Selection.style).toHaveBeenCalledWith(
|
||||
'fill',
|
||||
'rgb(100, 150, 200)',
|
||||
);
|
||||
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
|
||||
});
|
||||
|
||||
test('restores default fill color on mouseout for country with no data', () => {
|
||||
const mockElement = document.createElement('path');
|
||||
mockElement.setAttribute('class', 'datamaps-subunit XXX');
|
||||
mockElement.style.fill = '#e0e0e0'; // Default border color
|
||||
mockElement.setAttribute('data-original-fill', '#e0e0e0');
|
||||
container.appendChild(mockElement);
|
||||
|
||||
let mouseoutHandler: MouseEventHandler | null = null;
|
||||
|
||||
const mockD3Selection: MockD3Selection = {
|
||||
attr: jest.fn((attrName: string, value?: string | null) => {
|
||||
if (value !== undefined) {
|
||||
if (value === null) {
|
||||
mockElement.removeAttribute(attrName);
|
||||
} else {
|
||||
mockElement.setAttribute(attrName, value);
|
||||
}
|
||||
return mockD3Selection;
|
||||
}
|
||||
return mockElement.getAttribute(attrName);
|
||||
}),
|
||||
style: jest.fn((styleName: string, value?: string) => {
|
||||
if (value !== undefined) {
|
||||
mockElement.style[styleName as any] = value;
|
||||
}
|
||||
return mockElement.style[styleName as any] || mockD3Selection;
|
||||
}),
|
||||
classed: jest.fn().mockReturnThis(),
|
||||
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
|
||||
|
||||
// Capture the mouseout handler (namespaced event)
|
||||
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
|
||||
if (event === 'mouseout.fillPreserve') {
|
||||
mouseoutHandler = handler;
|
||||
}
|
||||
return mockSvg;
|
||||
});
|
||||
|
||||
WorldMap(container, baseProps);
|
||||
|
||||
// Simulate mouseout
|
||||
if (mouseoutHandler) {
|
||||
(mouseoutHandler as MouseEventHandler).call(mockElement);
|
||||
}
|
||||
|
||||
// Verify that default fill was restored (no-data color)
|
||||
expect(mockD3Selection.style).toHaveBeenCalledWith('fill', '#e0e0e0');
|
||||
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
|
||||
});
|
||||
|
||||
test('does not handle mouse events when inContextMenu is true', () => {
|
||||
const propsWithContextMenu = {
|
||||
...baseProps,
|
||||
inContextMenu: true,
|
||||
};
|
||||
|
||||
const mockElement = document.createElement('path');
|
||||
mockElement.setAttribute('class', 'datamaps-subunit USA');
|
||||
mockElement.style.fill = 'rgb(100, 150, 200)';
|
||||
container.appendChild(mockElement);
|
||||
|
||||
let mouseoverHandler: MouseEventHandler | null = null;
|
||||
let mouseoutHandler: MouseEventHandler | null = null;
|
||||
|
||||
const mockD3Selection: MockD3Selection = {
|
||||
attr: jest.fn(() => mockD3Selection),
|
||||
style: jest.fn(() => mockD3Selection),
|
||||
classed: jest.fn().mockReturnThis(),
|
||||
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
|
||||
|
||||
// Capture namespaced event handlers
|
||||
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
|
||||
if (event === 'mouseover.fillPreserve') {
|
||||
mouseoverHandler = handler;
|
||||
}
|
||||
if (event === 'mouseout.fillPreserve') {
|
||||
mouseoutHandler = handler;
|
||||
}
|
||||
return mockSvg;
|
||||
});
|
||||
|
||||
WorldMap(container, propsWithContextMenu);
|
||||
|
||||
// Simulate mouseover and mouseout
|
||||
if (mouseoverHandler) {
|
||||
(mouseoverHandler as MouseEventHandler).call(mockElement);
|
||||
}
|
||||
if (mouseoutHandler) {
|
||||
(mouseoutHandler as MouseEventHandler).call(mockElement);
|
||||
}
|
||||
|
||||
// When inContextMenu is true, handlers should exit early without modifying anything
|
||||
// We verify this by checking that attr and style weren't called to change fill
|
||||
const attrCalls = mockD3Selection.attr.mock.calls;
|
||||
const fillChangeCalls = attrCalls.filter(
|
||||
(call: [string, unknown]) =>
|
||||
call[0] === 'data-original-fill' && call[1] !== undefined,
|
||||
);
|
||||
const styleCalls = mockD3Selection.style.mock.calls;
|
||||
const fillStyleChangeCalls = styleCalls.filter(
|
||||
(call: [string, unknown]) => call[0] === 'fill' && call[1] !== undefined,
|
||||
);
|
||||
// The handlers should return early, so no state changes
|
||||
expect(fillChangeCalls.length).toBe(0);
|
||||
expect(fillStyleChangeCalls.length).toBe(0);
|
||||
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(geographyConfig?.highlightOnHover).toBe(false);
|
||||
});
|
||||
|
||||
test('does not throw error when onContextMenu is undefined', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
D3_TIME_FORMAT_OPTIONS,
|
||||
D3_FORMAT_DOCS,
|
||||
D3_FORMAT_OPTIONS,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
/*
|
||||
@@ -235,7 +236,7 @@ export const xAxisFormat: CustomControlItem = {
|
||||
label: t('X Axis Format'),
|
||||
renderTrigger: true,
|
||||
choices: D3_TIME_FORMAT_OPTIONS,
|
||||
default: 'smart_date',
|
||||
default: DEFAULT_TIME_FORMAT,
|
||||
description: D3_FORMAT_DOCS,
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user