mirror of
https://github.com/apache/superset.git
synced 2026-08-18 14:11:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58fa19bf90 | ||
|
|
1991e3f0d2 | ||
|
|
cfd40bdd0d | ||
|
|
a8216e3787 | ||
|
|
d114eb638b | ||
|
|
70d06e3d77 | ||
|
|
a2c004266b | ||
|
|
2773bc94eb | ||
|
|
94459ae885 | ||
|
|
ad3103fdb0 | ||
|
|
065342f8c9 | ||
|
|
2e8a8031f8 | ||
|
|
cece082eed | ||
|
|
22dde07a8e | ||
|
|
93cc520482 | ||
|
|
7679c5641a | ||
|
|
f7c70568c3 | ||
|
|
69c4011a95 | ||
|
|
4684851336 | ||
|
|
e7139a7ac2 | ||
|
|
21ae918656 | ||
|
|
ca94026e97 | ||
|
|
a3bc2d908c | ||
|
|
738d12677a | ||
|
|
2eedc609a8 | ||
|
|
f4587218dd | ||
|
|
8967e6c2d3 | ||
|
|
81e431cd50 | ||
|
|
edfb009e1c | ||
|
|
e808fcbcad | ||
|
|
0a7ebe1dd1 | ||
|
|
dd1afb029f | ||
|
|
c068a8c09c | ||
|
|
b62ec512d2 | ||
|
|
1e65d93a83 | ||
|
|
856599027a | ||
|
|
c395b9a238 | ||
|
|
92728169de | ||
|
|
84c371d56e | ||
|
|
a4c47359e6 | ||
|
|
7c0c5283c3 | ||
|
|
9c5bde9491 | ||
|
|
3b99e092d0 | ||
|
|
acf39e3ef0 | ||
|
|
8523ea4d0a | ||
|
|
9bc3173e3a | ||
|
|
a6db0d1cde | ||
|
|
aec567f7d6 | ||
|
|
bacaf08a22 |
+1
-1
@@ -19,7 +19,7 @@
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
/docs/developer_portal/extensions/ @michael-s-molina @villebro @rusackas
|
||||
/docs/developer_docs/extensions/ @michael-s-molina @villebro @rusackas
|
||||
/superset-extensions-cli/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
/superset/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
/superset-frontend/src/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
|
||||
@@ -21,6 +21,9 @@ on:
|
||||
#schedule:
|
||||
# - cron: '0 0 * * *' # Runs daily at midnight UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bump-python-package:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -16,6 +16,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check_db_migration_conflict:
|
||||
name: Check DB migration conflict
|
||||
|
||||
@@ -14,6 +14,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -51,6 +51,53 @@ jobs:
|
||||
echo "matrix_config=${MATRIX_CONFIG}" >> $GITHUB_OUTPUT
|
||||
echo $GITHUB_OUTPUT
|
||||
|
||||
# Runs unconditionally (no dependency on `changes`, and no build-preset
|
||||
# matrix restriction) so a regression in the PY_VER override logic is
|
||||
# always caught on PRs. Without this, the real docker-build job only runs
|
||||
# when the change detector flags docker/python/frontend changes (a
|
||||
# workflow-only edit like this one does not), and even then the PR build
|
||||
# matrix never includes the "py311"/"py312" presets that logic protects -
|
||||
# so a break here would otherwise first surface on a push to master.
|
||||
pyver-override-check:
|
||||
name: verify docker build PY_VER override
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
- name: Assert PY_VER override applies to every preset except py311/py312
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Asserts against the actual buildx command line `supersetbot docker
|
||||
# --dry-run` would run, not just this repo's own extra-flags helper,
|
||||
# so a regression in supersetbot itself (dropping the py311/py312
|
||||
# PY_VER pin, or reordering args so our override no longer lands
|
||||
# last) is caught here too, instead of only surfacing on master.
|
||||
assert_effective_py_ver() {
|
||||
local preset="$1" expected="$2" extra_flags command actual
|
||||
extra_flags="$(scripts/docker-build-extra-flags.sh "$preset" dummy-tag)"
|
||||
command="$(supersetbot docker --preset "$preset" --platform linux/amd64 --extra-flags "$extra_flags" --dry-run)"
|
||||
# docker buildx keeps the LAST value of a repeated --build-arg key.
|
||||
actual="$(grep -oE -- '--build-arg PY_VER=[^[:space:]]+' <<<"$command" | tail -1)"
|
||||
if [ "$actual" != "--build-arg PY_VER=$expected" ]; then
|
||||
echo "::error::preset '$preset' expected effective --build-arg PY_VER=$expected, got: ${actual:-<none>} (full command: $command)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
for preset in dev lean websocket dockerize; do
|
||||
assert_effective_py_ver "$preset" "3.11.14-slim-trixie"
|
||||
done
|
||||
assert_effective_py_ver py311 "3.11-slim-bookworm"
|
||||
assert_effective_py_ver py312 "3.12-slim-bookworm"
|
||||
echo "PY_VER override logic verified against the assembled buildx command for all build presets"
|
||||
|
||||
docker-build:
|
||||
name: docker-build
|
||||
needs: [setup_matrix, changes]
|
||||
@@ -124,19 +171,21 @@ jobs:
|
||||
# the whole job. buildx reuses the buildkit layer cache from the
|
||||
# failed attempt, so a retry mostly re-does just the failed push.
|
||||
#
|
||||
# supersetbot's "dev"/"lean" presets pin their own --build-arg
|
||||
# PY_VER, which lands ahead of --extra-flags on the assembled
|
||||
# buildx command line; docker/buildx keeps the last value for a
|
||||
# repeated --build-arg key, so appending PY_VER here overrides
|
||||
# supersetbot's pin and keeps the build on the Dockerfile's own
|
||||
# supported Python version.
|
||||
# See scripts/docker-build-extra-flags.sh for why "py311"/"py312"
|
||||
# are excluded from the PY_VER override applied to every other
|
||||
# preset; that logic is also exercised on every PR by the
|
||||
# always-on pyver-override-check job below, since this job itself
|
||||
# only runs when the change detector flags docker/python/frontend
|
||||
# changes and the PR build matrix never includes py311/py312.
|
||||
EXTRA_FLAGS="$(scripts/docker-build-extra-flags.sh "$BUILD_PRESET" "$IMAGE_TAG")"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
if supersetbot docker \
|
||||
$PUSH_OR_LOAD \
|
||||
--preset "$BUILD_PRESET" \
|
||||
--context "$EVENT" \
|
||||
--context-ref "$RELEASE" $FORCE_LATEST \
|
||||
--extra-flags "--build-arg PY_VER=3.11.14-slim-trixie --build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
|
||||
--extra-flags "$EXTRA_FLAGS" \
|
||||
$PLATFORM_ARG; then
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -33,6 +33,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
enforce-single-migration-head:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
name: Frontend bundle size (nightly baseline + analyzer)
|
||||
|
||||
# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size`
|
||||
# job compares PRs against, and publishes a browsable bundle-analyzer treemap
|
||||
# report of the same build. Deliberately NOT triggered on every push to
|
||||
# master: a day-old baseline/report is fine for catching relative
|
||||
# regressions on PRs and for browsing what's actually in the bundle, and
|
||||
# building the production bundle on every one of the many pushes master
|
||||
# gets per day would burn CI time for no benefit a nightly refresh doesn't
|
||||
# already cover.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TAG: apache/superset:bundle-size-nightly-${{ github.run_id }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
refresh-baseline:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }}
|
||||
steps:
|
||||
- name: "Checkout master"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t $TAG \
|
||||
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
|
||||
--target superset-node-ci \
|
||||
.
|
||||
|
||||
# Same cache the PR-time bundle-size job restores/writes -- webpack's
|
||||
# persistent filesystem cache turns a warm production build into ~20s
|
||||
# instead of several minutes. See superset-frontend.yml for the
|
||||
# matching restore step and why it's keyed this way.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry. Absent on the very first run ever; benchmark-action starts a
|
||||
# fresh history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
# BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS --
|
||||
# they're independent env-gated additions in webpack.config.js (one
|
||||
# sets `config.stats`, the other pushes plugins), so one production
|
||||
# build produces both the numeric stats.json and the analyzer's
|
||||
# report.html. Only report.html is mounted out, not
|
||||
# BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is
|
||||
# documented in webpack.config.js as routinely exceeding 100MB for
|
||||
# this app (it's .gitignore'd for exactly that reason), too large to
|
||||
# publish as a static site page.
|
||||
- name: Build production bundle with stats and analyzer report
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
mkdir -p ${{ github.workspace }}/superset/static/assets
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
-v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# No PR to comment on here, so comment-on-alert is off -- the job
|
||||
# summary (summary-always) is the only surface for this run.
|
||||
- name: Update bundle size baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
- name: Save bundle size history
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
|
||||
# Publishes the treemap to Netlify (the same host already used for
|
||||
# superset-storybook.netlify.app and docs previews, reusing the
|
||||
# existing NETLIFY_AUTH_TOKEN). Skipped until
|
||||
# NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free)
|
||||
# Netlify site named superset-bundle-analyzer and add its site ID as
|
||||
# that secret to turn this on; nothing else in this workflow depends
|
||||
# on it.
|
||||
- name: Publish bundle analyzer report to Netlify
|
||||
if: ${{ env.NETLIFY_SITE_ID != '' }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
run: |
|
||||
mkdir -p netlify-publish
|
||||
cp superset/static/assets/report.html netlify-publish/index.html
|
||||
# zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy
|
||||
# tool, not an application dependency; a global/npx install has no
|
||||
# lockfile context. Version pinned above the floor set by other
|
||||
# ad-hoc installs in this repo (bump deliberately when upgrading).
|
||||
npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish
|
||||
@@ -45,5 +45,8 @@ jobs:
|
||||
- name: Run Script
|
||||
run: bash .github/workflows/github-action-validator.sh
|
||||
|
||||
- name: Test docs-deploy freshness gate
|
||||
run: bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
|
||||
|
||||
- name: Check for security issues on GHA workflows
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
superbot-orglabel:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -7,6 +7,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
|
||||
@@ -3,6 +3,9 @@ on:
|
||||
release:
|
||||
types: [published] # This makes it run only when a new released is published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
latest-release:
|
||||
name: Add/update tag to new release
|
||||
|
||||
@@ -19,6 +19,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-check:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Shared freshness gate used by the Docs Deployment workflow
|
||||
# (superset-docs-deploy.yml) both up front (check-freshness) and again right
|
||||
# before the deploy step (recheck-freshness). Writes an output declaring
|
||||
# whether BUILD_SHA is still master's current tip, so a superseded run can
|
||||
# skip cleanly instead of racing (and clobbering, or being force-cancelled
|
||||
# by) a fresher run.
|
||||
#
|
||||
# Required env vars:
|
||||
# BUILD_SHA - the commit SHA this run is building
|
||||
# REPO - "owner/repo" to query, e.g. github.repository
|
||||
# OUTPUT_NAME - the GITHUB_OUTPUT key to write, e.g. "is-current"
|
||||
# GITHUB_OUTPUT - path to append outputs to (set by the Actions runner)
|
||||
# Optional env vars:
|
||||
# EVENT_NAME - if "workflow_dispatch", bypasses the check and always
|
||||
# reports current, since a manual dispatch is a deliberate,
|
||||
# one-off action rather than something racing other triggers
|
||||
# GH_TOKEN - passed through to `gh`, needed to call the GitHub API
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then
|
||||
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
latest_sha="$(gh api "repos/${REPO}/commits/master" --jq .sha)"
|
||||
if [ "${latest_sha}" = "${BUILD_SHA}" ]; then
|
||||
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "${OUTPUT_NAME}=false" >>"$GITHUB_OUTPUT"
|
||||
echo "::notice::master has moved on to ${latest_sha} since ${BUILD_SHA} was triggered — skipping this stale run."
|
||||
fi
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Exercises check-docs-deploy-freshness.sh against a stubbed `gh`, covering
|
||||
# the dispatch-bypass, current-tip and stale-tip branches so the output
|
||||
# contract (is-current / still-current) can't silently regress. Run
|
||||
# directly, no extra tooling required:
|
||||
# bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
script_under_test="${script_dir}/check-docs-deploy-freshness.sh"
|
||||
|
||||
failures=0
|
||||
|
||||
# Runs the script under test with a stubbed `gh` reporting $1 as master's
|
||||
# latest sha, asserting that GITHUB_OUTPUT ends up containing exactly $4.
|
||||
run_case() {
|
||||
local case_name="$1"
|
||||
local latest_sha="$2"
|
||||
local build_sha="$3"
|
||||
local event_name="$4"
|
||||
local expected_line="$5"
|
||||
|
||||
local workdir
|
||||
workdir="$(mktemp -d)"
|
||||
trap 'rm -rf "${workdir}"' RETURN
|
||||
|
||||
# Fake `gh` that just echoes back the requested "latest" sha regardless of
|
||||
# arguments, so the script under test never touches the network.
|
||||
cat >"${workdir}/gh" <<EOF
|
||||
#!/bin/bash
|
||||
echo '${latest_sha}'
|
||||
EOF
|
||||
chmod +x "${workdir}/gh"
|
||||
|
||||
local output_file="${workdir}/github_output"
|
||||
: >"${output_file}"
|
||||
|
||||
if PATH="${workdir}:${PATH}" \
|
||||
GITHUB_OUTPUT="${output_file}" \
|
||||
OUTPUT_NAME="is-current" \
|
||||
REPO="apache/superset" \
|
||||
BUILD_SHA="${build_sha}" \
|
||||
EVENT_NAME="${event_name}" \
|
||||
GH_TOKEN="fake-token" \
|
||||
bash "${script_under_test}"; then
|
||||
:
|
||||
else
|
||||
echo "FAIL (${case_name}): script exited non-zero"
|
||||
failures=$((failures + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
local actual
|
||||
actual="$(cat "${output_file}")"
|
||||
if [ "${actual}" = "${expected_line}" ]; then
|
||||
echo "PASS (${case_name})"
|
||||
else
|
||||
echo "FAIL (${case_name}): expected '${expected_line}', got '${actual}'"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# `gh` prints "should-not-be-called" for the dispatch case above the trick:
|
||||
# it's never actually invoked since the bypass short-circuits before the
|
||||
# `gh api` call, but the fake still needs a body.
|
||||
run_case "workflow_dispatch bypasses the check" \
|
||||
"unused" "abc123" "workflow_dispatch" \
|
||||
"is-current=true"
|
||||
|
||||
run_case "build sha matches master's tip" \
|
||||
"abc123" "abc123" "push" \
|
||||
"is-current=true"
|
||||
|
||||
run_case "build sha is stale" \
|
||||
"def456" "abc123" "push" \
|
||||
"is-current=false"
|
||||
|
||||
if [ "${failures}" -gt 0 ]; then
|
||||
echo "${failures} case(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All cases passed"
|
||||
@@ -17,6 +17,9 @@ env:
|
||||
GITHUB_ORG: ${{ github.repository_owner }}
|
||||
GITHUB_REPO: ${{ github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup-expired:
|
||||
name: Clean up expired showtime environments
|
||||
|
||||
@@ -26,6 +26,9 @@ env:
|
||||
GITHUB_REPO: ${{ github.event.repository.name }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: 🎪 Sync PR to desired state
|
||||
|
||||
@@ -18,16 +18,6 @@ on:
|
||||
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
|
||||
# in-progress run as soon as a newer one starts — the destination repo
|
||||
# isn't touched until the final push step, so canceling mid-build is safe,
|
||||
# and the freshest content always wins.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -48,17 +38,69 @@ jobs:
|
||||
|
||||
env:
|
||||
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
|
||||
|
||||
# Master gets frequent, sometimes bursty pushes, and each one can trigger a
|
||||
# deploy attempt. Rather than let every superseded attempt get force-killed
|
||||
# by the build-deploy concurrency group below (which shows up as a
|
||||
# `cancelled` — i.e. red/failing-looking — check on that commit), have each
|
||||
# run check up front whether it's still building master's current tip and,
|
||||
# if not, skip cleanly. Deliberately outside the docs-deploy-asf-site
|
||||
# concurrency group so it runs immediately for every trigger without
|
||||
# blocking or being blocked by anything.
|
||||
check-freshness:
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
is-current: ${{ steps.check.outputs.is-current }}
|
||||
steps:
|
||||
# Sparse checkout: this job's only job is to be fast, so it fetches
|
||||
# nothing but the freshness-check script itself.
|
||||
- name: Checkout freshness-check script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/workflows/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: "Check whether this is still master's current commit"
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
OUTPUT_NAME: is-current
|
||||
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
|
||||
|
||||
build-deploy:
|
||||
needs: config
|
||||
needs: [config, check-freshness]
|
||||
# Only the run for master's current tip proceeds; anything superseded
|
||||
# already skipped at check-freshness above instead of landing here.
|
||||
# For workflow_run triggers, only deploy when the triggering run originated
|
||||
# from this repository (not a fork), ensuring the checked-out code and any
|
||||
# local actions executed with deploy credentials are trusted.
|
||||
if: >-
|
||||
needs.config.outputs.has-secrets &&
|
||||
needs.check-freshness.outputs.is-current == 'true' &&
|
||||
(github.event_name != 'workflow_run' ||
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-26.04
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Queue instead of
|
||||
# canceling: a run that already passed check-freshness can still be
|
||||
# sitting in the queue for a runner when a newer run starts and finishes
|
||||
# first. cancel-in-progress would let that stale, queued run kill the
|
||||
# newer run's in-progress deploy the moment it's finally scheduled, and
|
||||
# then skip itself at the re-check below — losing the deploy entirely.
|
||||
# Queuing means the stale run just waits its turn and then no-ops at the
|
||||
# re-check, so the fresher content that already deployed is never
|
||||
# clobbered or lost. The check-freshness gate above means it should be
|
||||
# rare for more than one run to reach this point, so the queue stays
|
||||
# short in practice.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
@@ -130,7 +172,24 @@ jobs:
|
||||
working-directory: docs
|
||||
run: |
|
||||
yarn build
|
||||
# The check-freshness job above narrows the window but doesn't close it: an
|
||||
# older run can observe is-current=true, then sit through this build while a
|
||||
# newer run's own freshness check also passes and it deploys and finishes
|
||||
# first. If this (stale) run then wins entry into the concurrency group, it
|
||||
# would overwrite the newer content that already deployed. Re-check right
|
||||
# before the one step that actually mutates superset-site, so a stale run
|
||||
# skips deploying instead of clobbering a fresher one that already ran.
|
||||
- name: "Re-check freshness immediately before deploying"
|
||||
id: recheck-freshness
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
OUTPUT_NAME: still-current
|
||||
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
|
||||
- name: deploy docs
|
||||
if: github.event_name == 'workflow_dispatch' || steps.recheck-freshness.outputs.still-current == 'true'
|
||||
uses: ./.github/actions/github-action-push-to-another-repository
|
||||
env:
|
||||
API_TOKEN_GITHUB: ${{ secrets.SUPERSET_SITE_BUILD }}
|
||||
|
||||
@@ -26,6 +26,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -212,3 +212,100 @@ jobs:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
# Compares a PR's own bundle size against the last nightly-recorded
|
||||
# baseline (see frontend-bundle-size-nightly.yml, which owns actually
|
||||
# persisting new baselines). PR-only: a push to master doesn't need this
|
||||
# check re-run against itself, and re-persisting the baseline on every
|
||||
# push to master -- which happens many times a day -- would burn a full
|
||||
# production build for no benefit nightly refresh doesn't already cover.
|
||||
bundle-size:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
- name: Load Docker Image
|
||||
run: |
|
||||
zstd -d < docker-image.tar.zst | docker load
|
||||
|
||||
# webpack's persistent filesystem cache (superset-frontend/webpack.config.js)
|
||||
# turns a warm production build into ~20s instead of several minutes,
|
||||
# but GH-hosted runners are fresh VMs with nothing carried over between
|
||||
# jobs -- without restoring it explicitly, every single PR would pay
|
||||
# the full cold-build cost. Keyed on the same files webpack's own
|
||||
# `buildDependencies` invalidates on, so a stale cache is never used.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry, which is always the latest nightly run. Absent before the
|
||||
# first nightly run ever happens; benchmark-action starts a fresh
|
||||
# history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
- name: Build production bundle with stats
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# Comparison + alert only -- this job never persists. See
|
||||
# frontend-bundle-size-nightly.yml for why.
|
||||
#
|
||||
# comment-on-alert is gated to same-repo PRs: on a fork PR,
|
||||
# GITHUB_TOKEN is forced read-only regardless of the `permissions`
|
||||
# block above, so once the alert threshold is crossed the action's
|
||||
# `pulls.createReview` call 403s. That error isn't gated by
|
||||
# fail-on-alert (which only governs the deliberate alert-threshold
|
||||
# failure) -- it propagates and fails the job outright. Fork PRs
|
||||
# still get the comparison via the job summary (summary-always).
|
||||
- name: Compare bundle size against nightly baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-on-alert: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
alert-threshold: "110%"
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
@@ -27,6 +27,9 @@ concurrency:
|
||||
group: helm-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -22,6 +22,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -14,6 +14,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -13,6 +13,9 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
supersetbot:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -167,7 +167,7 @@ The Developer Portal auto-generates MDX documentation from Storybook stories. **
|
||||
### Generator Location
|
||||
- Script: `docs/scripts/generate-superset-components.mjs`
|
||||
- Wrapper: `docs/src/components/StorybookWrapper.jsx`
|
||||
- Output: `docs/developer_portal/components/`
|
||||
- Output: `docs/developer_docs/components/`
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
|
||||
+1
-1
@@ -35,4 +35,4 @@ The Developer Portal includes comprehensive guides for:
|
||||
- [Code Review Process](https://superset.apache.org/developer_portal/contributing/code-review)
|
||||
- [Development How-tos](https://superset.apache.org/developer_portal/contributing/howtos)
|
||||
|
||||
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_portal).
|
||||
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_docs).
|
||||
|
||||
@@ -31,6 +31,8 @@ under the License.
|
||||
[](https://github.com/apache/superset/pulls)
|
||||
[](https://bit.ly/join-superset-slack)
|
||||
[](https://superset.apache.org)
|
||||
[](https://superset-storybook.netlify.app)
|
||||
[](https://superset-bundle-analyzer.netlify.app)
|
||||
|
||||
<picture width="500">
|
||||
<source
|
||||
|
||||
@@ -97,6 +97,54 @@ for more information on how to configure it.
|
||||
|
||||
At the very least, you'll want to change `SECRET_KEY` and `SQLALCHEMY_DATABASE_URI`. Continue reading for more about each of these.
|
||||
|
||||
## Localizing D3 date and time labels
|
||||
|
||||
`BABEL_DEFAULT_LOCALE` controls Superset's application translations, while
|
||||
`D3_TIME_FORMAT` provides localized date and time names to visualizations that
|
||||
use the D3 formatter registry, including Calendar Heatmap. Configure both when
|
||||
you want the application and chart labels to use the same locale.
|
||||
|
||||
`D3_TIME_FORMAT` accepts partial overrides. For example, Russian month names
|
||||
can be configured in `superset_config.py` as follows:
|
||||
|
||||
```python
|
||||
BABEL_DEFAULT_LOCALE = "ru"
|
||||
|
||||
D3_TIME_FORMAT = {
|
||||
"months": [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
],
|
||||
"shortMonths": [
|
||||
"Янв",
|
||||
"Фев",
|
||||
"Мар",
|
||||
"Апр",
|
||||
"Май",
|
||||
"Июн",
|
||||
"Июл",
|
||||
"Авг",
|
||||
"Сен",
|
||||
"Окт",
|
||||
"Ноя",
|
||||
"Дек",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Restart Superset after changing `superset_config.py` so the frontend receives
|
||||
the updated formatter configuration.
|
||||
|
||||
## Chart-data query timing
|
||||
|
||||
Set `CHART_DATA_INCLUDE_TIMING = True` to add an optional versioned timing object
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
title: Dashboard Performance
|
||||
hide_title: true
|
||||
sidebar_position: 5
|
||||
version: 1
|
||||
---
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Dashboard Performance
|
||||
|
||||
A dashboard's perceived speed is determined by three independent things: how
|
||||
many charts have to render, how many queries the backend can execute
|
||||
concurrently, and how quickly the underlying data warehouse can return
|
||||
results. Superset gives you levers for the first two; the third belongs to
|
||||
your warehouse. This page covers the dashboard-side levers and the practical
|
||||
guidance around them.
|
||||
|
||||
## Is there a maximum chart count per dashboard?
|
||||
|
||||
**No hard limit is enforced** — Superset has no configuration key that
|
||||
caps the number of charts on a dashboard. In practice, dashboards behave
|
||||
well up to a few dozen charts. Beyond that, you'll typically feel friction
|
||||
on the initial load and during cross-filter / time-range updates, even with
|
||||
the lazy-loading optimizations described below.
|
||||
|
||||
Rough thresholds to keep in mind:
|
||||
|
||||
- **Under ~25 charts**: usually no perceptible problem.
|
||||
- **25–50 charts**: still fine, but you start to want tabs to break the
|
||||
page into chunks the user actually looks at.
|
||||
- **Over ~50 charts**: split into multiple dashboards or use tabs
|
||||
aggressively. The bottleneck is rarely Superset itself — it's the
|
||||
warehouse executing dozens of queries in parallel and the browser
|
||||
rendering dozens of chart frames.
|
||||
|
||||
These are guidelines, not guarantees. A dashboard of 100 sparkline-style
|
||||
charts hitting a fast cache behaves very differently from a dashboard of
|
||||
20 heavy aggregations against a cold warehouse.
|
||||
|
||||
## Lazy rendering — `DASHBOARD_VIRTUALIZATION`
|
||||
|
||||
Superset's dashboard layout is virtualized at the row level. Charts that
|
||||
are far below the user's current scroll position render a placeholder
|
||||
instead of their visualization until the user scrolls them into view, and
|
||||
go back to a placeholder if scrolled well past. The chart component itself
|
||||
stays mounted throughout — only the visualization is swapped for a
|
||||
placeholder — so this alone does **not** reduce backend query load; see
|
||||
[Deferred data fetch](#deferred-data-fetch--dashboard_virtualization_defer_data)
|
||||
below for that. This is on by default.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION` (default: `True`)
|
||||
|
||||
The flag is `stable` and marked for path-to-deprecation — meaning the
|
||||
behavior will eventually be non-optional, but the flag still exists so
|
||||
operators can disable it if a specific layout misbehaves.
|
||||
|
||||
**Behavior** (from `superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx`):
|
||||
|
||||
- A chart's visualization is rendered when its row scrolls within **1
|
||||
viewport height** of the visible area.
|
||||
- A chart's visualization is swapped back for a placeholder when its row
|
||||
scrolls more than **4 viewport heights** away from the visible area.
|
||||
- Tabs that aren't currently selected don't render their content at all
|
||||
(see below).
|
||||
- The placeholder-swap-back is skipped in **embedded** mode (so an
|
||||
embedded dashboard keeps its charts rendered once they've been seen,
|
||||
which avoids re-rendering on scroll-up). Both halves are skipped for
|
||||
**headless / bot** rendering (so screenshot / report jobs load every
|
||||
chart).
|
||||
|
||||
## Deferred data fetch — `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
|
||||
|
||||
By default, `DASHBOARD_VIRTUALIZATION` only controls whether a chart's
|
||||
*visualization* is rendered — the chart component still mounts and issues
|
||||
its data request immediately, regardless of scroll position.
|
||||
`DASHBOARD_VIRTUALIZATION_DEFER_DATA` is a supplementary flag that skips
|
||||
the data request itself for charts that aren't currently in view, useful
|
||||
for backends where opening a connection or compiling a query is expensive
|
||||
even if the result would be thrown away. It only has an effect when
|
||||
`DASHBOARD_VIRTUALIZATION` is also enabled — with virtualization off,
|
||||
every chart is treated as in view, so there's nothing left to defer.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION_DEFER_DATA` (default: `False`)
|
||||
|
||||
Enable this if you see warehouse load spike on dashboard *open* even
|
||||
though most charts are off-screen.
|
||||
|
||||
## Per-tab lazy loading
|
||||
|
||||
**This is on by default and has no flag.** A tab's content is not rendered
|
||||
until the user activates that tab, so charts inside an unselected tab do
|
||||
not fetch data on dashboard open. When the user clicks the tab, that
|
||||
tab's charts mount and fetch in the normal way.
|
||||
|
||||
Practically: tabs are the single most effective tool for a large
|
||||
dashboard. Splitting 60 charts across 4 tabs effectively turns dashboard
|
||||
open into "load ~15 charts," and the remaining ones lazy-load only if the
|
||||
user goes looking.
|
||||
|
||||
## Is there a switch to cap concurrent chart queries?
|
||||
|
||||
**No.** Superset does not implement a frontend-side concurrent-request
|
||||
limiter. Each chart issues its own data request when it mounts, and the
|
||||
browser handles parallelism — typically ~6 in-flight requests per origin
|
||||
under HTTP/1.1, though HTTP/2 or HTTP/3 (if your deployment terminates
|
||||
TLS that way) can multiplex considerably more over a single connection.
|
||||
Backend throughput is bounded by your
|
||||
Gunicorn worker count for synchronous query execution, or by your Celery
|
||||
worker pool when [async queries](./async-queries-celery.mdx) are enabled.
|
||||
|
||||
If you need to throttle warehouse load, the right place is:
|
||||
|
||||
1. The warehouse itself (connection pool / concurrency limits).
|
||||
2. Superset's Celery configuration (smaller worker pool when async
|
||||
queries are on).
|
||||
3. Splitting heavy charts across tabs or separate dashboards (each
|
||||
dashboard load only fetches what's visible).
|
||||
|
||||
## Splitting strategies
|
||||
|
||||
When a dashboard outgrows comfortable performance, the options in order
|
||||
of effort:
|
||||
|
||||
**1. Move sections into tabs.** Same dashboard, but only the active tab's
|
||||
charts fetch. This is the cheapest change and often the only one needed.
|
||||
|
||||
**2. Cache aggressively.** A Redis cache backend (see
|
||||
[Caching](./cache.mdx)) means repeat dashboard loads serve from cache
|
||||
rather than re-hitting the warehouse. This is especially impactful for
|
||||
dashboards opened by many users in close succession.
|
||||
|
||||
**3. Enable async queries.** [Async query execution](./async-queries-celery.mdx)
|
||||
via Celery decouples query duration from request lifetime, so a slow
|
||||
chart doesn't block the page. The user sees other charts come in as
|
||||
their queries complete.
|
||||
|
||||
**4. Split into multiple dashboards.** Group related charts into purpose-
|
||||
specific dashboards rather than one mega-dashboard. Link them from a
|
||||
landing dashboard or a navigation menu.
|
||||
|
||||
**5. Pre-aggregate at the warehouse level.** If the same expensive
|
||||
aggregation appears across many charts, materialize it as a view or
|
||||
scheduled table in the warehouse so each chart query is a cheap lookup.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The feature flags above are set in `superset_config.py`, e.g.:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"DASHBOARD_VIRTUALIZATION": True,
|
||||
"DASHBOARD_VIRTUALIZATION_DEFER_DATA": True,
|
||||
}
|
||||
```
|
||||
|
||||
- See [Feature Flags](./feature-flags.mdx) for the full list of supported
|
||||
flags and their lifecycle stages.
|
||||
- Server-side screenshot jobs (alerts, scheduled reports, thumbnails)
|
||||
render the dashboard in a headless, webdriver-controlled browser, which
|
||||
intentionally bypasses row virtualization so the rendered artifact
|
||||
includes every chart, not just the ones above the fold. User-triggered
|
||||
"download as image/PDF" is different: it captures whatever's currently
|
||||
rendered in the user's own browser, so it's still subject to
|
||||
virtualization like any other page view. Metadata/YAML dashboard export
|
||||
doesn't render the frontend at all, so virtualization doesn't apply to
|
||||
it either.
|
||||
@@ -519,6 +519,30 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.0/install
|
||||
|
||||
For those interested, you may also try out [avn](https://github.com/nvm-sh/nvm#deeper-shell-integration) to automatically switch to the node version that is required to run Superset frontend.
|
||||
|
||||
##### zstd
|
||||
|
||||
`npm run dev-server` proxies requests to your local Superset server and rewrites the HTML it returns, so it has to decompress responses sent with `Content-Encoding: zstd`. It does that with [`simple-zstd`](https://www.npmjs.com/package/simple-zstd), which wraps the system `zstd` binary instead of bundling one. That binary has to be on your `PATH`:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install zstd
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install zstd
|
||||
|
||||
# Windows
|
||||
choco install zstd
|
||||
```
|
||||
|
||||
`simple-zstd` looks for the binary when it is first imported, not when a response is decompressed, so a missing `zstd` stops the dev server at startup with:
|
||||
|
||||
```
|
||||
Error: Can not access zstd! Is it installed?
|
||||
at Object.<anonymous> (.../node_modules/simple-zstd/dist/src/index.js:102:11)
|
||||
```
|
||||
|
||||
The message names the dependency, but it surfaces from inside `webpack.proxy-config.js` while the webpack config is loading, which reads like a build-tooling failure rather than a missing system package.
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
Install third-party dependencies listed in `package.json` via:
|
||||
|
||||
@@ -198,7 +198,7 @@ Each component should come with its dedicated storybook file.
|
||||
|
||||
**One component per story:** Each storybook file should only contain one component unless substantially different variants are required
|
||||
|
||||
**Component variants:** If the component behavior is substantially different when certain props are used, it is best to separate the story into different types. See the `superset-frontend/src/components/Select/Select.stories.tsx` as an example.
|
||||
**Component variants:** If the component behavior is substantially different when certain props are used, it is best to separate the story into different types. See the `superset-frontend/packages/superset-ui-core/src/components/Select/Select.stories.tsx` as an example.
|
||||
|
||||
**Isolated state:** The storybook should show how the component works in an isolated state and with as few dependencies as possible
|
||||
|
||||
|
||||
+49
-5
@@ -46,13 +46,43 @@ import FAQSchema from '@site/src/components/FAQSchema';
|
||||
answer:
|
||||
'You need to register a free account at Mapbox.com, obtain an API key, and add it to your .env file at the key MAPBOX_API_KEY.',
|
||||
},
|
||||
{
|
||||
question: 'How to limit the timed refresh on a dashboard?',
|
||||
answer:
|
||||
'To exclude specific slices from the timed refresh process, add the timed_refresh_immune_slices key to the dashboard JSON Metadata field with the slice IDs to exclude.',
|
||||
},
|
||||
{
|
||||
question: "Why does 'flask fab' or Superset freeze, hang, or not respond when started (my home directory is NFS mounted)?",
|
||||
answer:
|
||||
'By default, Superset creates and uses an SQLite database at ~/.superset/superset.db. SQLite is known to not work well if used on NFS due to broken file locking. Override the path with the SUPERSET_HOME environment variable or change SQLALCHEMY_DATABASE_URI in superset_config.py.',
|
||||
},
|
||||
{
|
||||
question: 'What if the table schema changed?',
|
||||
answer:
|
||||
'Go to Data -> Datasets, click the edit icon next to the dataset whose schema has changed, and hit Sync columns from source from the Columns tab. The new columns will get merged.',
|
||||
},
|
||||
{
|
||||
question: 'What database engine can I use as a backend for Superset?',
|
||||
answer:
|
||||
'Superset is tested using MySQL, PostgreSQL, and SQLite backends for storing its internal metadata. While Superset supports many databases as data sources, only these are recommended for the metadata store in production.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset work with my database?',
|
||||
question: 'How can I configure OAuth authentication and authorization?',
|
||||
answer:
|
||||
'Take a look at the Flask-AppBuilder OAuth configuration example, which shows how to configure OAuth authentication and authorization in Superset.',
|
||||
},
|
||||
{
|
||||
question: 'Is there a way to force the dashboard to use specific colors?',
|
||||
answer:
|
||||
'It is possible on a per-dashboard basis by providing a mapping of labels to colors in the JSON Metadata attribute using the label_colors key. You can use a full hex color, a named color, or the index in the current color palette.',
|
||||
},
|
||||
{
|
||||
question: 'How do I expand all chart descriptions on a dashboard by default?',
|
||||
answer:
|
||||
'Add the expand_all_slices key to the dashboard JSON Metadata field with a value of true. Charts that have already been manually expanded or collapsed keep that per-chart override regardless of the setting.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset work with [insert database engine here]?',
|
||||
answer:
|
||||
'Superset supports any database with a Python SQLAlchemy dialect and DBAPI driver. Check the Connecting to Databases documentation for the full list of supported databases.',
|
||||
},
|
||||
@@ -61,15 +91,30 @@ import FAQSchema from '@site/src/components/FAQSchema';
|
||||
answer:
|
||||
'Yes, Superset has a public REST API documented using Swagger. Enable FAB_API_SWAGGER_UI in superset_config.py to access interactive API documentation at /swagger/v1.',
|
||||
},
|
||||
{
|
||||
question: 'How can I see usage statistics (e.g., monthly active users)?',
|
||||
answer:
|
||||
'This functionality is not included with Superset, but you can extract and analyze the logs table in Superset\u2019s metadata database to see what actions have occurred.',
|
||||
},
|
||||
{
|
||||
question: 'What does Hours Offset in the Edit Dataset view do?',
|
||||
answer:
|
||||
'In the Edit Dataset view, the hours offset lets you configure the number of hours to be added or subtracted from the time column. This can be used, for example, to convert UTC time to local time.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset collect any telemetry data?',
|
||||
answer:
|
||||
'Superset uses Scarf by default to collect basic telemetry data to help maintainers understand version usage. Users can opt out by setting the SCARF_ANALYTICS environment variable to false.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset have a trash bin to recover deleted assets?',
|
||||
question: 'Does Superset have an archive panel or trash bin from which a user can recover deleted assets?',
|
||||
answer:
|
||||
'No, there is no built-in way to recover deleted dashboards, charts, or datasets. It is recommended to take periodic backups of the metadata database and use export functionality for recovery.',
|
||||
'No. Currently, there is no way to recover a deleted Superset dashboard, chart, dataset, or database from the UI. It is recommended to take periodic backups of the metadata database and use export functionality for recovery.',
|
||||
},
|
||||
{
|
||||
question: 'I ran a security scan of the Superset container image and it showed dozens of "high" and "critical" vulnerabilities! Can you release a version of Superset without these?',
|
||||
answer:
|
||||
'These are dependency CVEs in software that Superset uses, mostly in the Linux kernel or Python. Superset addresses them by regularly updating dependencies and welcomes pull requests that fix dependency CVEs. The Superset security team focuses primarily on vulnerabilities in Superset itself.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -214,8 +259,7 @@ frontend falls back to a built-in default of `5000` milliseconds.
|
||||
SUPERSET_DASHBOARD_MANUAL_REFRESH_STAGGER_MS = 0
|
||||
```
|
||||
|
||||
**Why does ‘flask fab’ or superset freeze/hang/not responding when started (my home directory is
|
||||
NFS mounted)?**
|
||||
## Why does `flask fab` or Superset freeze, hang, or not respond when started (my home directory is NFS mounted)?
|
||||
|
||||
By default, Superset creates and uses an SQLite database at `~/.superset/superset.db`. SQLite is
|
||||
known to [not work well if used on NFS](https://www.sqlite.org/lockingv3.html) due to broken file
|
||||
|
||||
+4
-4
@@ -58,12 +58,12 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.6",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.3",
|
||||
"antd": "^6.5.4",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"caniuse-lite": "^1.0.30001807",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.6",
|
||||
"storybook": "^10.5.7",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
|
||||
+26
-26
@@ -4095,23 +4095,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^10.5.6":
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.6.tgz#445d4e0992a0862a22bffcea321ee2cb034846b5"
|
||||
integrity sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==
|
||||
"@storybook/addon-docs@^10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.7.tgz#6d599c94fc871c248ce06a5c081f57655c83f40a"
|
||||
integrity sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.6"
|
||||
"@storybook/csf-plugin" "10.5.7"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.6"
|
||||
"@storybook/react-dom-shim" "10.5.7"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/csf-plugin@10.5.6":
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.6.tgz#9fca28f5fd7d545a32638bb4f08902f6887072b2"
|
||||
integrity sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==
|
||||
"@storybook/csf-plugin@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz#bc73f164d1b5f8e2931b2774f4b389a06453cf6e"
|
||||
integrity sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==
|
||||
dependencies:
|
||||
unplugin "^2.3.5"
|
||||
|
||||
@@ -4125,10 +4125,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
|
||||
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
|
||||
|
||||
"@storybook/react-dom-shim@10.5.6":
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz#3685605c9dd27298fada7fef264801b2e7d62cbb"
|
||||
integrity sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==
|
||||
"@storybook/react-dom-shim@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz#9a5aa0e0f89c09e71c6cbfc6bb1abeb537e5aabf"
|
||||
integrity sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -6164,10 +6164,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.5.3:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.3.tgz#3c7d2ec4a20be116f72b7fbbdb4497d65ffd6dae"
|
||||
integrity sha512-Q5r8sztf9Yk9B70bSUjnPYMCJ4A/eZM7uMoTj8UAhlSKR9aftjEuBEPcNSmRux7hB+87rxO8vN1X4HNjR97qyQ==
|
||||
antd@^6.5.4:
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.4.tgz#b41665e86a5f46ca761abd3b0abef7460116ca0d"
|
||||
integrity sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6745,10 +6745,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001806:
|
||||
version "1.0.30001806"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz#1bc8e502b723fa393455dfbedd5ccec0c29bb74e"
|
||||
integrity sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001807:
|
||||
version "1.0.30001807"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz#a113854941fb45b4c1f51793f4636920489079b4"
|
||||
integrity sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -14765,10 +14765,10 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^10.5.6:
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.6.tgz#c91f22f617f3718dd06c58c87b46ff66f4ce7bf5"
|
||||
integrity sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==
|
||||
storybook@^10.5.7:
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
|
||||
integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
|
||||
+6
-6
@@ -42,7 +42,7 @@ dependencies = [
|
||||
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
|
||||
# explicitly to keep fresh ``pip install apache-superset`` working
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=7.1.6, <8",
|
||||
"cachetools>=7.1.7, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.2",
|
||||
"click-option-group",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"Mako>=1.2.2",
|
||||
"Mako>=1.4.1",
|
||||
"markdown>=3.10.3",
|
||||
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
|
||||
# Flask-AppBuilder workaround. Tracking issue:
|
||||
@@ -135,10 +135,10 @@ athena = ["pyathena[pandas]>=3.35.4, <4"]
|
||||
# superset/db_engine_specs/aurora.py's known_incompatibilities metadata.
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.35.0",
|
||||
"pandas-gbq>=0.35.1",
|
||||
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
|
||||
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
|
||||
"sqlalchemy-bigquery>=1.17.1",
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
@@ -185,7 +185,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
# installing this extra is only required to actually run exports.
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.5,<4.0",
|
||||
"fastmcp>=3.4.6,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
@@ -221,7 +221,7 @@ ocient = [
|
||||
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
|
||||
# unverified. Lower confidence than the other bumps in this PR.
|
||||
"sqlalchemy-ocient>=3.0.0, <4",
|
||||
"pyocient>=1.0.15, <4",
|
||||
"pyocient>=3.9.0, <4",
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
|
||||
@@ -28,7 +28,7 @@ numexpr>=2.9.0
|
||||
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
|
||||
cryptography>=50.0.0,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.3.11,<2.0.0
|
||||
mako>=1.4.1,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
pyarrow>=24.0.0,<26.0.0
|
||||
# Security: CVE-2026-27459 - pyopenssl certificate validation
|
||||
|
||||
@@ -44,7 +44,7 @@ cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.7
|
||||
# via apache-superset (pyproject.toml)
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -207,7 +207,7 @@ kombu==5.6.2
|
||||
# via celery
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.3.12
|
||||
mako==1.4.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
|
||||
@@ -99,7 +99,7 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.7
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -239,9 +239,9 @@ et-xmlfile==2.0.0
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp-slim
|
||||
fastmcp==3.4.5
|
||||
fastmcp==3.4.7
|
||||
# via apache-superset
|
||||
fastmcp-slim==3.4.5
|
||||
fastmcp-slim==3.4.7
|
||||
# via fastmcp
|
||||
filelock==3.20.3
|
||||
# via
|
||||
@@ -510,7 +510,7 @@ limits==5.1.0
|
||||
# flask-limiter
|
||||
lz4==4.4.5
|
||||
# via trino
|
||||
mako==1.3.12
|
||||
mako==1.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -640,7 +640,7 @@ pandas==2.3.3
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
# prophet
|
||||
pandas-gbq==0.35.0
|
||||
pandas-gbq==0.35.1
|
||||
# via apache-superset
|
||||
parameterized==0.9.0
|
||||
# via apache-superset
|
||||
@@ -964,7 +964,7 @@ sqlalchemy==2.0.51
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.17.1
|
||||
sqlalchemy-bigquery==1.17.2
|
||||
# via apache-superset
|
||||
sqlalchemy-continuum==1.7.0
|
||||
# via
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
|
||||
# Computes the `--extra-flags` value passed to `supersetbot docker` for a
|
||||
# given build preset. Factored out of .github/workflows/docker.yml so the
|
||||
# PY_VER override logic below can be exercised by an always-on CI check
|
||||
# (docker.yml's docker-build job only runs when the change detector's
|
||||
# docker/python/frontend outputs are true, and the PR build matrix never
|
||||
# includes py311/py312 at all, so a regression here would otherwise go
|
||||
# unnoticed until the fix actually runs on master) without duplicating -
|
||||
# and risking drift from - the logic used by the real build step.
|
||||
#
|
||||
# supersetbot's "py311"/"py312" presets pin their own --build-arg PY_VER,
|
||||
# which lands ahead of --extra-flags on the assembled buildx command line;
|
||||
# docker/buildx keeps the last value for a repeated --build-arg key, so
|
||||
# appending PY_VER here would override supersetbot's pin and silently make
|
||||
# "py311"/"py312" build the exact same image as "lean". Every other preset
|
||||
# gets the override so its build lands on the Dockerfile's own supported
|
||||
# Python version.
|
||||
#
|
||||
# Usage: docker-build-extra-flags.sh <build_preset> <image_tag>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_PRESET="${1:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
|
||||
IMAGE_TAG="${2:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
|
||||
|
||||
EXTRA_FLAGS="--build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG"
|
||||
if [ "$BUILD_PRESET" != "py311" ] && [ "$BUILD_PRESET" != "py312" ]; then
|
||||
EXTRA_FLAGS="--build-arg PY_VER=3.11.14-slim-trixie $EXTRA_FLAGS"
|
||||
fi
|
||||
|
||||
echo "$EXTRA_FLAGS"
|
||||
Generated
+506
@@ -19,6 +19,7 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
@@ -26,6 +27,27 @@
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/cli": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz",
|
||||
@@ -1656,6 +1678,121 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz",
|
||||
@@ -2501,6 +2638,16 @@
|
||||
"acorn": "^8.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -2868,6 +3015,34 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2886,6 +3061,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2915,6 +3097,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/envinfo": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz",
|
||||
@@ -3316,6 +3511,60 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
|
||||
@@ -3438,6 +3687,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3498,6 +3754,46 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3977,6 +4273,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4033,6 +4336,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4142,6 +4458,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -4314,6 +4640,33 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
@@ -4447,6 +4800,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@@ -4592,6 +4952,26 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -4605,6 +4985,32 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tscw-config": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tscw-config/-/tscw-config-1.1.2.tgz",
|
||||
@@ -4931,6 +5337,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
@@ -4944,6 +5363,16 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.105.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
|
||||
@@ -5070,6 +5499,44 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5114,6 +5581,45 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Switchboard } from "@superset-ui/switchboard";
|
||||
import { embedDashboard } from "./index";
|
||||
|
||||
vi.mock("@superset-ui/switchboard");
|
||||
|
||||
function makeFakeJWT(claims: any) {
|
||||
// not a valid jwt, but close enough for this code
|
||||
const tokenifiedClaims = Buffer.from(JSON.stringify(claims)).toString(
|
||||
"base64",
|
||||
);
|
||||
return `abc.${tokenifiedClaims}.xyz`;
|
||||
}
|
||||
|
||||
describe("embedDashboard", () => {
|
||||
let mountPoint: HTMLElement;
|
||||
let mockSwitchboard: Switchboard;
|
||||
|
||||
beforeEach(() => {
|
||||
mountPoint = document.createElement("div");
|
||||
document.body.appendChild(mountPoint);
|
||||
|
||||
mockSwitchboard = {
|
||||
emit: vi.fn(),
|
||||
get: vi.fn(),
|
||||
start: vi.fn(),
|
||||
defineMethod: vi.fn(),
|
||||
} as any;
|
||||
|
||||
// Constructor mocks must use `function`, since arrow functions cannot be
|
||||
// invoked with `new`.
|
||||
vi.mocked(Switchboard).mockImplementation(function () {
|
||||
return mockSwitchboard;
|
||||
} as any);
|
||||
|
||||
// Mock MessageChannel API
|
||||
globalThis.MessageChannel = vi.fn(function (this: any) {
|
||||
this.port1 = {};
|
||||
this.port2 = {};
|
||||
}) as any;
|
||||
|
||||
// Mock iframe load event and sandbox
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(tagName => {
|
||||
const element = originalCreateElement(tagName);
|
||||
if (tagName === "iframe") {
|
||||
// Mock sandbox DOMTokenList
|
||||
(element as any).sandbox = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
// Mock contentWindow for postMessage
|
||||
Object.defineProperty(element, "contentWindow", {
|
||||
writable: true,
|
||||
value: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
element.dispatchEvent(new Event("load"));
|
||||
}, 0);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(mountPoint);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("setDataMask sends dataMask to iframe", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const testDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["value1", "value2"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(testDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: testDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends empty dataMask", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const emptyDataMask = {};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(emptyDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: emptyDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask drops the change-trigger flags observeDataMask adds", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const observedMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA"],
|
||||
},
|
||||
},
|
||||
crossFiltersChanged: false,
|
||||
nativeFiltersChanged: true,
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(observedMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: {
|
||||
"NATIVE_FILTER-1": observedMask["NATIVE_FILTER-1"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends complex dataMask with multiple filters", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const complexDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA", "NY"],
|
||||
},
|
||||
},
|
||||
"NATIVE_FILTER-2": {
|
||||
filterState: {
|
||||
value: [2023, 2024],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(complexDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: complexDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask rejects when the embedded page does not support it", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
vi.mocked(mockSwitchboard.get).mockRejectedValue(
|
||||
new Error('Method "setDataMask" is not defined'),
|
||||
);
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
await expect(dashboard.setDataMask({})).rejects.toThrow(
|
||||
'Method "setDataMask" is not defined',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,12 @@ export type EmbeddedDashboard = {
|
||||
getActiveTabs: () => Promise<string[]>;
|
||||
observeDataMask: (callbackFn: ObserveDataMaskCallbackFn) => void;
|
||||
getDataMask: () => Promise<Record<string, any>>;
|
||||
/**
|
||||
* Applies a data mask to the dashboard.
|
||||
* Rejects if the embedded Superset page does not support `setDataMask`,
|
||||
* so a version mismatch surfaces instead of silently doing nothing.
|
||||
*/
|
||||
setDataMask: (dataMask: Record<string, any>) => Promise<void>;
|
||||
getChartStates: () => Promise<Record<string, any>>;
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
@@ -355,6 +361,21 @@ export async function embedDashboard({
|
||||
ourPort.get<string>("getDashboardPermalink", { anchor });
|
||||
const getActiveTabs = () => ourPort.get<string[]>("getActiveTabs");
|
||||
const getDataMask = () => ourPort.get<Record<string, any>>("getDataMask");
|
||||
// `observeDataMask` hands the host a mask with the change-trigger booleans
|
||||
// mixed in, so feeding that payload straight back into `setDataMask` is a
|
||||
// natural thing for a host to do. Keep only the entries that look like a
|
||||
// filter's mask, so those flags never reach the dashboard as filter ids.
|
||||
// Sent with `get` rather than `emit` so the iframe acknowledges the call:
|
||||
// an embedded page that predates `setDataMask` replies with an error instead
|
||||
// of dropping the message silently.
|
||||
const setDataMask = (dataMask: Record<string, any>) =>
|
||||
ourPort.get<void>("setDataMask", {
|
||||
dataMask: Object.fromEntries(
|
||||
Object.entries(dataMask).filter(
|
||||
([, mask]) => typeof mask === "object" && mask !== null,
|
||||
),
|
||||
),
|
||||
});
|
||||
const getChartStates = () =>
|
||||
ourPort.get<Record<string, any>>("getChartStates");
|
||||
const getChartDataPayloads = (params?: { chartId?: number }) =>
|
||||
@@ -396,6 +417,7 @@ export async function embedDashboard({
|
||||
getActiveTabs,
|
||||
observeDataMask,
|
||||
getDataMask,
|
||||
setDataMask,
|
||||
getChartStates,
|
||||
getChartDataPayloads,
|
||||
setThemeConfig,
|
||||
|
||||
Generated
+104
-95
@@ -33,7 +33,7 @@
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@googleapis/sheets": "^14.0.0",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.3",
|
||||
"antd": "^6.5.4",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -108,7 +108,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -116,7 +116,7 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"ol": "^10.10.0",
|
||||
"query-string": "9.4.1",
|
||||
"query-string": "9.5.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.16.0",
|
||||
@@ -180,9 +180,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.6",
|
||||
"@storybook/addon-links": "10.5.6",
|
||||
"@storybook/react-webpack5": "10.5.6",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
@@ -235,7 +235,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.6",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -266,13 +266,13 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.9",
|
||||
"tsx": "^4.23.10",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -4142,9 +4142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@googleapis/sheets": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.2.tgz",
|
||||
"integrity": "sha512-b1tBlMcfvNEziM4DZCikLOc9iqSlgCK1e5bMKtNQIADRXr1CQmbkHV3ZBVvTsFsjLErgihqO58Itn/kzCnSZ0A==",
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-14.0.0.tgz",
|
||||
"integrity": "sha512-fANEl4RQohsPYUWhcLSYyUyE8A8bRfvw/bp8h0t8VDQqTgdQ3itZBkty4nddtdqCAvNDpA+KM66OejVKDd6aFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"googleapis-common": "^8.0.0"
|
||||
@@ -10741,16 +10741,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.6.tgz",
|
||||
"integrity": "sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz",
|
||||
"integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.5.6",
|
||||
"@storybook/csf-plugin": "10.5.7",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.5.6",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -10761,7 +10761,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10770,9 +10770,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.6.tgz",
|
||||
"integrity": "sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz",
|
||||
"integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10785,7 +10785,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -10805,9 +10805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz",
|
||||
"integrity": "sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -10819,7 +10819,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10831,9 +10831,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.6.tgz",
|
||||
"integrity": "sha512-pw+OS/wUZ4ijdVGOsE5QOt59+C2i4fwtFBs2ircB7KMlwEE7gslovZEjnrz8bbaznvmoLoYGWYZxXcLi+bYmzg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.7.tgz",
|
||||
"integrity": "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10846,7 +10846,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10940,15 +10940,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.6.tgz",
|
||||
"integrity": "sha512-UdsC+IrZHBAtEvvDkfCPhg5sy5jnJAHT4RS3I8wNHVJ+93gaUrZLElSIV+w5UB1u/yghoqmQm7/LfpPpZrjPZQ==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-vvl07oXp2qfmHJHZ77Aw1F3LFOo7XubOta+lC8UmlEw3rDDJhQxJN3erJJVHavNhdA2jBTK6VUXQKdqQh7X7nQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.5.6",
|
||||
"@storybook/preset-react-webpack": "10.5.6",
|
||||
"@storybook/react": "10.5.6"
|
||||
"@storybook/builder-webpack5": "10.5.7",
|
||||
"@storybook/preset-react-webpack": "10.5.7",
|
||||
"@storybook/react": "10.5.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -10957,7 +10957,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -10967,13 +10967,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.6.tgz",
|
||||
"integrity": "sha512-uWo/MzNC6HXMEpy8QQfbeYh1j6aOC6Ly0sAR6RE0LPvpyGEWC0VaVOBERIJJBjeuPuonDBVKfEjh1iUvZhJopg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-4n4c60LihFivZnjAcXGO5+XbgZthoUtKb/nPKVgypj3MpEetzjq6XR83A4UNnRsXYmjqfn6bsDWNgEJ/RvQg5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.6",
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"cjs-module-lexer": "^1.2.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -10995,7 +10995,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11004,9 +11004,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.6.tgz",
|
||||
"integrity": "sha512-o5PP3K+NcJAitZF7Ywweow0d8dJrEA1jxV5T1LMGMiWHUrnpoaPTiK1HcYw39pOQkdKL88mMSfDDUipGurZthw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11017,17 +11017,17 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.6.tgz",
|
||||
"integrity": "sha512-QPUl2t+0VIp1Wy7JfqvV8cI1NrULUt+XFMKdIaNp39TuyMn3El4txvmxQWKhcYvnSOEzQ2SGNDqSWcJISwmeAA==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-xwNRcoVlIDx1/YYCFBAxfh/91vFiOgrVI+0Ir4u9eO87SH2leehRnJh619QEOrlQEU5px487y2BmL2ZVtmTpYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.6",
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -11044,7 +11044,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11053,9 +11053,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.6.tgz",
|
||||
"integrity": "sha512-o5PP3K+NcJAitZF7Ywweow0d8dJrEA1jxV5T1LMGMiWHUrnpoaPTiK1HcYw39pOQkdKL88mMSfDDUipGurZthw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11066,18 +11066,18 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.6.tgz",
|
||||
"integrity": "sha512-dXSdNoc9yAvpa4hiegQhmZPXOKunAxkPX94DxvRw/kM6+wujVFAGlZjYygKrWw357KOjPRK7SO1LRTc70mgrhQ==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz",
|
||||
"integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.5.6",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -11090,7 +11090,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -11106,9 +11106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz",
|
||||
"integrity": "sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -11120,7 +11120,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -15214,9 +15214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.5.3.tgz",
|
||||
"integrity": "sha512-Q5r8sztf9Yk9B70bSUjnPYMCJ4A/eZM7uMoTj8UAhlSKR9aftjEuBEPcNSmRux7hB+87rxO8vN1X4HNjR97qyQ==",
|
||||
"version": "6.5.4",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.5.4.tgz",
|
||||
"integrity": "sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.1",
|
||||
@@ -18913,9 +18913,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/decode-uri-component": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz",
|
||||
"integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.5.0.tgz",
|
||||
"integrity": "sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
@@ -20522,9 +20522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz",
|
||||
"integrity": "sha512-uOXhNkIH+iTdyViSmWnCrwtapasL57M3nq5yfST1H7y9djRLyuAIfNcf9cPBedc2G1oqI8jn3up/VHdN3y3Btw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20533,7 +20533,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -28748,9 +28748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.0.tgz",
|
||||
"integrity": "sha512-WEbvl2ju0MUZ+R83HeCosmJBTyYdhmFcajeQ7kwLyJ0EHUw9YG/k2QLcMmAQ8sXZpkWq1BbmfjT5lh/oInOnCw==",
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
|
||||
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -33246,9 +33246,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -33668,12 +33668,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "9.4.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.1.tgz",
|
||||
"integrity": "sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA==",
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.5.0.tgz",
|
||||
"integrity": "sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.4.1",
|
||||
"decode-uri-component": "^0.5.0",
|
||||
"filter-obj": "^5.1.0",
|
||||
"split-on-first": "^3.0.0"
|
||||
},
|
||||
@@ -34955,9 +34955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-checkbox-tree/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -37991,9 +37991,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.6.tgz",
|
||||
"integrity": "sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -39966,9 +39966,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.9",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.9.tgz",
|
||||
"integrity": "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==",
|
||||
"version": "4.23.10",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.10.tgz",
|
||||
"integrity": "sha512-0Vb9eKU47njkxv/6B8CRZRDsxNDT/Pz+BIU+M5jw7xL3TdzAjSxlZUxu0xFL/kLpaG3sHZ0LH2wbK1T1yo7CUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43353,6 +43353,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
@@ -43936,7 +43945,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@googleapis/sheets": "^14.0.0",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -158,7 +158,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.3",
|
||||
"antd": "^6.5.4",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -185,7 +185,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -193,7 +193,7 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"ol": "^10.10.0",
|
||||
"query-string": "9.4.1",
|
||||
"query-string": "9.5.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.16.0",
|
||||
@@ -257,9 +257,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.6",
|
||||
"@storybook/addon-links": "10.5.6",
|
||||
"@storybook/react-webpack5": "10.5.6",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
@@ -312,7 +312,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.6",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -343,13 +343,13 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.9",
|
||||
"tsx": "^4.23.10",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -415,6 +415,7 @@
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nanoid@>=3 <4": "3.3.18",
|
||||
"nwsapi": "^2.2.13",
|
||||
"puppeteer": "^22.4.1",
|
||||
"tar": "^7.5.16",
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getSequentialSchemeRegistry } from '@superset-ui/core';
|
||||
import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import CalHeatMapImport from './vendor/cal-heatmap';
|
||||
import { convertUTCTimestampToLocal } from './utils';
|
||||
import { convertUTCTimestampToLocal, getFormattedUTCTime } from './utils';
|
||||
|
||||
// The vendor file is @ts-nocheck, so its export lacks type info.
|
||||
// Define a minimal constructor interface for use in this file.
|
||||
@@ -103,6 +103,8 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
const subDomainTextFormat = showValues
|
||||
? (_date: Date, value: number) => valueFormatter(value)
|
||||
: null;
|
||||
const dateFormatter = (date: Date, format: string) =>
|
||||
getFormattedUTCTime(date.getTime(), format);
|
||||
|
||||
const metricsData = data.data;
|
||||
|
||||
@@ -166,6 +168,7 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
itemName: '',
|
||||
valueFormatter,
|
||||
timeFormatter,
|
||||
dateFormatter,
|
||||
subDomainTextFormat,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,8 @@ var CalHeatMap = function () {
|
||||
|
||||
timeFormatter: d => d,
|
||||
|
||||
dateFormatter: null,
|
||||
|
||||
domain: 'hour',
|
||||
|
||||
subDomain: 'min',
|
||||
@@ -1990,10 +1992,14 @@ CalHeatMap.prototype = {
|
||||
|
||||
if (typeof format === 'function') {
|
||||
return format(d);
|
||||
} else {
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
}
|
||||
|
||||
if (typeof this.options.dateFormatter === 'function') {
|
||||
return this.options.dateFormatter(d, format);
|
||||
}
|
||||
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
},
|
||||
|
||||
getSubDomainTitle: function (d) {
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
|
||||
import { convertUTCTimestampToLocal } from '../src/utils';
|
||||
|
||||
interface MockCalHeatMapConfig {
|
||||
itemSelector: Element;
|
||||
dateFormatter?: (date: Date, format: string) => string;
|
||||
}
|
||||
|
||||
type MetricNameInput = string | string[];
|
||||
@@ -38,6 +40,7 @@ let mockInitCallCount = 0;
|
||||
let mockThrowOnInitCall: number | null = null;
|
||||
let mockDestroyCallCount = 0;
|
||||
let mockDestroyedInstanceIds: string[] = [];
|
||||
let mockDateFormatter: MockCalHeatMapConfig['dateFormatter'];
|
||||
|
||||
const mockTheme = {
|
||||
colorBgElevated: '#ffffff',
|
||||
@@ -56,6 +59,7 @@ jest.mock('../src/vendor/cal-heatmap', () => ({
|
||||
} = require('../src/tooltip');
|
||||
|
||||
mockInitCallCount += 1;
|
||||
mockDateFormatter = config.dateFormatter;
|
||||
if (mockThrowOnInitCall === mockInitCallCount) {
|
||||
throw new Error('Mock CalHeatMap init failure');
|
||||
}
|
||||
@@ -284,9 +288,28 @@ afterEach(() => {
|
||||
mockThrowOnInitCall = null;
|
||||
mockDestroyCallCount = 0;
|
||||
mockDestroyedInstanceIds = [];
|
||||
mockDateFormatter = undefined;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('Calendar provides a timezone-safe date formatter to CalHeatMap', () => {
|
||||
const calendarOwner = document.createElement('div');
|
||||
document.body.appendChild(calendarOwner);
|
||||
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps('localized-metric'),
|
||||
theme: mockTheme,
|
||||
});
|
||||
|
||||
if (!mockDateFormatter) {
|
||||
throw new Error('Expected Calendar to configure a date formatter');
|
||||
}
|
||||
|
||||
const localDate = new Date(convertUTCTimestampToLocal(Date.UTC(2024, 0, 1)));
|
||||
|
||||
expect(mockDateFormatter(localDate, '%Y-%m-%d')).toBe('2024-01-01');
|
||||
});
|
||||
|
||||
test('rerender and unmount clean up only the affected calendar tooltips', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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 CalHeatMapImport from '../src/vendor/cal-heatmap';
|
||||
|
||||
type DateFormatter = (date: Date, format: string) => string;
|
||||
type FunctionalDateFormat = (date: Date) => string;
|
||||
|
||||
interface CalHeatMapInstance {
|
||||
options: {
|
||||
dateFormatter: DateFormatter | null;
|
||||
};
|
||||
formatDate(date: Date, format: string | FunctionalDateFormat): string;
|
||||
}
|
||||
|
||||
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
|
||||
|
||||
test('CalHeatMap delegates string date formats to the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'Январь');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('Январь');
|
||||
expect(dateFormatter).toHaveBeenCalledWith(date, '%B');
|
||||
});
|
||||
|
||||
test('CalHeatMap preserves functional formatters over the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'localized');
|
||||
const functionalFormat = jest.fn<string, [Date]>(() => 'custom');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, functionalFormat)).toBe('custom');
|
||||
expect(functionalFormat).toHaveBeenCalledWith(date);
|
||||
expect(dateFormatter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('CalHeatMap keeps the D3 formatter fallback', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('January');
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 { SqlaFormData } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import { EchartsBubbleChartProps } from './types';
|
||||
|
||||
const baseFormData: SqlaFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'bubble_v2',
|
||||
entity: 'customer_name',
|
||||
x: 'price',
|
||||
y: 'sales',
|
||||
size: 'count',
|
||||
};
|
||||
|
||||
const baseChartProps = {
|
||||
width: 400,
|
||||
height: 400,
|
||||
hooks: {},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ customer_name: 'A', price: 10, sales: 100, count: 5 },
|
||||
{ customer_name: 'B', price: 20, sales: 200, count: 8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: { colorText: '#000' },
|
||||
};
|
||||
|
||||
test('nests xAxisLabelInterval under axisLabel rather than the axis itself', () => {
|
||||
// Regression test: xAxis.interval forces echarts' IntervalScale into a
|
||||
// fixed-tick-spacing mode that expects a number and crashes on the
|
||||
// 'auto'/'0' strings this control actually produces (observed as an
|
||||
// uncaught assertion deep in echarts' axis "nice" tick calculation,
|
||||
// reproducing on every dashboard bubble chart). The interval belongs on
|
||||
// axisLabel, where it only controls how many labels are skipped.
|
||||
const { echartOptions } = transformProps({
|
||||
...baseChartProps,
|
||||
formData: baseFormData,
|
||||
} as unknown as EchartsBubbleChartProps);
|
||||
|
||||
expect((echartOptions.xAxis as any).interval).toBeUndefined();
|
||||
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('auto');
|
||||
});
|
||||
|
||||
test('honors an explicit xAxisLabelInterval override', () => {
|
||||
const { echartOptions } = transformProps({
|
||||
...baseChartProps,
|
||||
formData: { ...baseFormData, xAxisLabelInterval: '0' },
|
||||
} as unknown as EchartsBubbleChartProps);
|
||||
|
||||
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('0');
|
||||
});
|
||||
@@ -212,13 +212,16 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
series,
|
||||
xAxis: {
|
||||
axisLabel: { formatter: xAxisFormatter, rotate: xAxisLabelRotation },
|
||||
axisLabel: {
|
||||
formatter: xAxisFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
interval: xAxisLabelInterval,
|
||||
scale: true,
|
||||
name: bubbleXAxisTitle,
|
||||
nameLocation: 'middle',
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Reduces a webpack `--json` stats file down to the handful of headline
|
||||
// numbers worth tracking over time, in the flat array format
|
||||
// benchmark-action/github-action-benchmark expects for its
|
||||
// "customSmallerIsBetter" tool. The full stats file also includes a
|
||||
// `modules`/`chunks` graph across ~15k modules, which is enormous and not
|
||||
// useful for this purpose, so we only ever read `entrypoints`.
|
||||
//
|
||||
// Usage: node scripts/bundle-size-summary.js <path-to-stats.json>
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Entrypoints worth tracking: the two user-facing app shells. `menu`,
|
||||
// `preamble`, `theme`, and `service-worker` are small, low-variance
|
||||
// infrastructure chunks, not where bundle bloat actually shows up.
|
||||
const TRACKED_ENTRYPOINTS = ['spa', 'embedded'];
|
||||
|
||||
function entrypointSizeByExt(entrypoint, ext) {
|
||||
return (entrypoint.assets || [])
|
||||
.filter(asset => asset.name.endsWith(ext))
|
||||
.reduce((total, asset) => total + asset.size, 0);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const statsPath = process.argv[2];
|
||||
if (!statsPath) {
|
||||
console.error('Usage: bundle-size-summary.js <path-to-stats.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
||||
const { entrypoints } = stats;
|
||||
if (!entrypoints) {
|
||||
console.error(
|
||||
'stats.json has no `entrypoints` key -- was it built with ' +
|
||||
'`BUNDLE_SIZE_STATS=true` set? Without it, webpack.config.js uses ' +
|
||||
'`stats: "minimal"`, which omits `entrypoints`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
TRACKED_ENTRYPOINTS.forEach(name => {
|
||||
const entrypoint = entrypoints[name];
|
||||
if (!entrypoint) {
|
||||
console.error(`stats.json is missing the "${name}" entrypoint`);
|
||||
process.exit(1);
|
||||
}
|
||||
results.push({
|
||||
name: `${name} entrypoint (JS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.js'),
|
||||
});
|
||||
results.push({
|
||||
name: `${name} entrypoint (CSS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.css'),
|
||||
});
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { entrypointSizeByExt, main, TRACKED_ENTRYPOINTS };
|
||||
@@ -35,6 +35,43 @@ if (SERVICE_ACCOUNT_KEY.client_email) {
|
||||
|
||||
const DATETIME = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
|
||||
|
||||
/**
|
||||
* Turn an oxlint diagnostic code into the canonical rule id used by the metrics
|
||||
* series.
|
||||
*
|
||||
* oxlint reports `<plugin>(<rule>)`, where the plugin is the linter the rule came
|
||||
* from: `eslint(no-console)`, `react-hooks(exhaustive-deps)`, `react(jsx-key)`,
|
||||
* `jest(no-conditional-expect)`, `oxc(erasing-op)`, and the legacy
|
||||
* `eslint-plugin-unicorn(no-new-array)` spelling.
|
||||
*
|
||||
* `eslint` is the implicit namespace, so its rules keep their bare name and stay
|
||||
* comparable with the rows recorded before the oxlint migration. Every other
|
||||
* plugin becomes `<plugin>/<rule>`, which is the id those rules are known by in
|
||||
* config and in the pre-migration history.
|
||||
*
|
||||
* @param {string | undefined} code the diagnostic's `code` field
|
||||
* @returns {string} the rule id to record
|
||||
*/
|
||||
function parseRuleId(code) {
|
||||
if (!code) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const match = code.match(/^([\w-]+)\(([^)]+)\)$/);
|
||||
if (!match) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const [, namespace, rule] = match;
|
||||
if (namespace === 'eslint') {
|
||||
return rule;
|
||||
}
|
||||
|
||||
// `eslint-plugin-unicorn(...)` is the same rule as `unicorn/...`
|
||||
const plugin = namespace.replace(/^eslint-plugin-/, '');
|
||||
return `${plugin}/${rule}`;
|
||||
}
|
||||
|
||||
async function writeToGoogleSheet(data, range, headers, append = false) {
|
||||
if (!sheets) {
|
||||
console.log('No Google Sheets credentials, skipping upload');
|
||||
@@ -101,17 +138,7 @@ async function runOxlintAndProcess() {
|
||||
// OXC JSON format has diagnostics array
|
||||
if (results.diagnostics && Array.isArray(results.diagnostics)) {
|
||||
results.diagnostics.forEach(diagnostic => {
|
||||
// Extract rule ID from code like "eslint(no-unused-vars)" or "eslint-plugin-unicorn(no-new-array)"
|
||||
const codeMatch = diagnostic.code?.match(
|
||||
/^(?:eslint(?:-plugin-(\w+))?\()([^)]+)\)$/,
|
||||
);
|
||||
let ruleId = diagnostic.code || 'unknown';
|
||||
|
||||
if (codeMatch) {
|
||||
const plugin = codeMatch[1];
|
||||
const rule = codeMatch[2];
|
||||
ruleId = plugin ? `${plugin}/${rule}` : rule;
|
||||
}
|
||||
const ruleId = parseRuleId(diagnostic.code);
|
||||
|
||||
const file = diagnostic.filename || 'unknown';
|
||||
const line = diagnostic.labels?.[0]?.span?.line || 0;
|
||||
@@ -251,5 +278,10 @@ async function runOxlintAndProcess() {
|
||||
}
|
||||
}
|
||||
|
||||
// Run the process
|
||||
runOxlintAndProcess().catch(console.error);
|
||||
// Run the process, unless this file was imported (e.g. by a test) rather than
|
||||
// executed, in which case nothing should be linted or uploaded on import.
|
||||
if (require.main === module) {
|
||||
runOxlintAndProcess().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { parseRuleId };
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const {
|
||||
entrypointSizeByExt,
|
||||
main,
|
||||
} = require('../../scripts/bundle-size-summary');
|
||||
|
||||
function mockStats(entrypoints) {
|
||||
jest
|
||||
.spyOn(fs, 'readFileSync')
|
||||
.mockReturnValue(JSON.stringify({ entrypoints }));
|
||||
}
|
||||
|
||||
function mockExit() {
|
||||
return jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
}
|
||||
|
||||
const originalArgv = process.argv;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt sums only assets matching the given extension', () => {
|
||||
const entrypoint = {
|
||||
assets: [
|
||||
{ name: 'spa.entry.js', size: 100 },
|
||||
{ name: 'spa.entry.js.map', size: 500 },
|
||||
{ name: 'spa.entry.css', size: 20 },
|
||||
],
|
||||
};
|
||||
expect(entrypointSizeByExt(entrypoint, '.js')).toBe(100);
|
||||
expect(entrypointSizeByExt(entrypoint, '.css')).toBe(20);
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt returns 0 when the entrypoint has no assets', () => {
|
||||
expect(entrypointSizeByExt({}, '.js')).toBe(0);
|
||||
});
|
||||
|
||||
test('main prints byte totals for every tracked entrypoint', () => {
|
||||
mockStats({
|
||||
spa: { assets: [{ name: 'spa.js', size: 100 }] },
|
||||
embedded: { assets: [{ name: 'embedded.js', size: 50 }] },
|
||||
});
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
main();
|
||||
|
||||
const printed = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(printed).toEqual([
|
||||
{ name: 'spa entrypoint (JS)', unit: 'bytes', value: 100 },
|
||||
{ name: 'spa entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
{ name: 'embedded entrypoint (JS)', unit: 'bytes', value: 50 },
|
||||
{ name: 'embedded entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('main exits with an error when a tracked entrypoint is missing from stats.json', () => {
|
||||
mockStats({ spa: { assets: [] } });
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('missing the "embedded" entrypoint'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main exits with an error when stats.json has no `entrypoints` key', () => {
|
||||
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({}));
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no `entrypoints` key'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main prints a usage message and exits when no stats path is given', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const { parseRuleId } = require('../../scripts/oxlint-metrics-uploader');
|
||||
|
||||
test('eslint rules keep their bare name', () => {
|
||||
expect(parseRuleId('eslint(no-console)')).toBe('no-console');
|
||||
expect(parseRuleId('eslint(prefer-destructuring)')).toBe(
|
||||
'prefer-destructuring',
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin rules are recorded under plugin/rule (#42981)', () => {
|
||||
// These are the codes oxlint actually emits. They previously fell through to
|
||||
// the raw `react-hooks(exhaustive-deps)` string, so the rows no longer lined
|
||||
// up with the ids the same rules were recorded under before the migration.
|
||||
expect(parseRuleId('react-hooks(exhaustive-deps)')).toBe(
|
||||
'react-hooks/exhaustive-deps',
|
||||
);
|
||||
expect(parseRuleId('react-hooks(rules-of-hooks)')).toBe(
|
||||
'react-hooks/rules-of-hooks',
|
||||
);
|
||||
expect(parseRuleId('react(jsx-key)')).toBe('react/jsx-key');
|
||||
expect(parseRuleId('jest(no-conditional-expect)')).toBe(
|
||||
'jest/no-conditional-expect',
|
||||
);
|
||||
expect(parseRuleId('oxc(erasing-op)')).toBe('oxc/erasing-op');
|
||||
expect(parseRuleId('typescript(no-explicit-any)')).toBe(
|
||||
'typescript/no-explicit-any',
|
||||
);
|
||||
});
|
||||
|
||||
test('the legacy eslint-plugin- prefix still collapses to the plugin name', () => {
|
||||
expect(parseRuleId('eslint-plugin-unicorn(no-new-array)')).toBe(
|
||||
'unicorn/no-new-array',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unrecognized or missing code is passed through rather than dropped', () => {
|
||||
expect(parseRuleId('something-unparseable')).toBe('something-unparseable');
|
||||
expect(parseRuleId(undefined)).toBe('unknown');
|
||||
expect(parseRuleId('')).toBe('unknown');
|
||||
});
|
||||
@@ -695,6 +695,49 @@ describe('sqlLabReducer', () => {
|
||||
);
|
||||
expect(newState.queries['sync-query'].state).toBe(QueryState.Fetching);
|
||||
});
|
||||
test('should move an async query from running to success when polling reports it finished', () => {
|
||||
const asyncQuery = {
|
||||
...query,
|
||||
id: 'async-query',
|
||||
state: QueryState.Running,
|
||||
runAsync: true,
|
||||
};
|
||||
newState = sqlLabReducer(
|
||||
{
|
||||
...newState,
|
||||
queries: { 'async-query': asyncQuery },
|
||||
},
|
||||
actions.refreshQueries({
|
||||
'async-query': {
|
||||
...asyncQuery,
|
||||
state: QueryState.Success,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(newState.queries['async-query'].state).toBe(QueryState.Success);
|
||||
});
|
||||
test('should downgrade a premature poller success to fetching for a running sync query', () => {
|
||||
const syncQuery = {
|
||||
...query,
|
||||
id: 'sync-running',
|
||||
state: QueryState.Running,
|
||||
runAsync: false,
|
||||
results: null,
|
||||
};
|
||||
newState = sqlLabReducer(
|
||||
{
|
||||
...newState,
|
||||
queries: { 'sync-running': syncQuery },
|
||||
},
|
||||
actions.refreshQueries({
|
||||
'sync-running': {
|
||||
...syncQuery,
|
||||
state: QueryState.Success,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(newState.queries['sync-running'].state).toBe(QueryState.Fetching);
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('CLEAR_INACTIVE_QUERIES', () => {
|
||||
|
||||
@@ -769,14 +769,15 @@ export default function sqlLabReducer(
|
||||
}),
|
||||
// race condition:
|
||||
// because of async behavior, sql lab may still poll a couple of seconds
|
||||
// when it started fetching or finished rendering results
|
||||
// after it started fetching or finished rendering results. Guard only
|
||||
// against re-applying a redundant Success onto a state that's already at
|
||||
// or past Success (Fetching/Success) — Running is strictly before
|
||||
// Success, so an incoming Success there is new information, not a stale
|
||||
// poll, and must be allowed through (otherwise an async query can never
|
||||
// leave Running once observed there).
|
||||
state:
|
||||
currentState === QueryState.Success &&
|
||||
[
|
||||
QueryState.Fetching,
|
||||
QueryState.Success,
|
||||
QueryState.Running,
|
||||
].includes(prevState)
|
||||
[QueryState.Fetching, QueryState.Success].includes(prevState)
|
||||
? prevState
|
||||
: currentState,
|
||||
};
|
||||
|
||||
+1
-1
@@ -867,7 +867,7 @@ function DatasourceEditor({
|
||||
return {
|
||||
...metric,
|
||||
certification_details: certificationDetails || details,
|
||||
warning_markdown: warningMarkdown || '',
|
||||
warning_markdown: warningMarkdown || metric.warning_markdown || '',
|
||||
certified_by: certifiedBy || certifiedByMetric,
|
||||
};
|
||||
}),
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
// Certifying a metric fills two adjacent fields in one visit to the expanded
|
||||
// row. Both are committed through TextControl's debounce, so the second one
|
||||
// used to land on the item as it looked before the first had been applied,
|
||||
// leaving the saved metric with details but no certifier.
|
||||
test('certifying a metric keeps both certified_by and certification_details', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Certified by'),
|
||||
'Metric Certifier',
|
||||
);
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Certification details'),
|
||||
'Metric cert details',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const { calls } = testProps.onChange.mock;
|
||||
const savedMetrics = calls[calls.length - 1]?.[0]?.metrics ?? [];
|
||||
const saved = savedMetrics.find(metric => metric.metric_name === 'count');
|
||||
expect(saved).toEqual(
|
||||
expect.objectContaining({
|
||||
certified_by: 'Metric Certifier',
|
||||
certification_details: 'Metric cert details',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
// Stub the Ace-backed control with a plain textarea. Ace spreads its document
|
||||
// across many spans and keeps only the keystroke buffer in its own textarea,
|
||||
// so asserting on the value the control receives is less brittle than
|
||||
// reaching into Ace's DOM.
|
||||
jest.mock('src/explore/components/controls/TextAreaControl', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
controlId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
controlId?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-test={`mock-textarea-${controlId}`}
|
||||
value={value ?? ''}
|
||||
onChange={event => onChange?.(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
// Regression test for #42704. Explore's datasource payload (SqlMetric.data on
|
||||
// the backend) exposes warning_markdown as a flattened top-level field and
|
||||
// omits the raw `extra` JSON string that the /api/v1/dataset/{id} endpoint
|
||||
// backing the Datasets page provides. Deriving warning_markdown purely from
|
||||
// `extra` therefore dropped the saved text when the modal was opened from
|
||||
// Explore, leaving the Warning field blank on reopen.
|
||||
test('keeps a pre-existing top-level warning_markdown when the metric has no extra', async () => {
|
||||
const baseProps = createProps();
|
||||
const testProps = {
|
||||
...baseProps,
|
||||
datasource: {
|
||||
...baseProps.datasource,
|
||||
metrics: [
|
||||
{
|
||||
...baseProps.datasource.metrics[0],
|
||||
warning_markdown: 'existing warning',
|
||||
extra: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
const metricsTab = await screen.findByTestId('collection-tab-Metrics');
|
||||
await userEvent.click(metricsTab);
|
||||
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('mock-textarea-warning_markdown'),
|
||||
).toHaveValue('existing warning');
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { Divider, Form, Typography } from '@superset-ui/core/components';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { recurseReactClone } from '../../utils';
|
||||
@@ -39,14 +39,24 @@ export default function Fieldset({
|
||||
title = null,
|
||||
compact = false,
|
||||
}: FieldsetProps) {
|
||||
// Controls report their edits asynchronously - TextControl debounces by
|
||||
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
|
||||
// earlier render. Spreading that render's `item` rebuilds the whole record
|
||||
// from a snapshot taken before a sibling field committed, dropping the value
|
||||
// the user typed first. Reading off a ref merges into the latest commit.
|
||||
const itemRef = useRef(item);
|
||||
useEffect(() => {
|
||||
itemRef.current = item;
|
||||
}, [item]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(fieldKey: fieldKeyType, val: any) => {
|
||||
onChange?.({
|
||||
...item,
|
||||
...itemRef.current,
|
||||
[fieldKey]: val,
|
||||
});
|
||||
},
|
||||
[onChange, item],
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { Dispatch } from 'redux';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { cloneDeep, omit } from 'lodash-es';
|
||||
import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions';
|
||||
import { HYDRATE_DASHBOARD } from './hydrate';
|
||||
import {
|
||||
@@ -90,12 +90,20 @@ export const setFilterConfiguration =
|
||||
});
|
||||
try {
|
||||
const response = await updateFilters(filterChanges);
|
||||
// chartsInScope/tabsInScope are derived from the live layout, and the
|
||||
// response carries the persisted copy for every filter - including the
|
||||
// ones this save never touched, whose copy is whatever was stored when
|
||||
// the dashboard was last saved. Dropping them lets the reducers keep the
|
||||
// scopes calculateScopes already computed for this session.
|
||||
const savedFilters = response.result.map(
|
||||
filter => omit(filter, ['chartsInScope', 'tabsInScope']) as Filter,
|
||||
);
|
||||
dispatch({
|
||||
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
|
||||
filterChanges: response.result,
|
||||
filterChanges: savedFilters,
|
||||
deletedIds: filterChanges.deleted,
|
||||
});
|
||||
dispatch(nativeFiltersConfigChanged(response.result));
|
||||
dispatch(nativeFiltersConfigChanged(savedFilters));
|
||||
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
|
||||
@@ -528,6 +528,57 @@ describe('PropertiesModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves certification fields on save without opening Certification section', async () => {
|
||||
// Accordion Collapse only mounts the active panel, so certifiedBy /
|
||||
// certificationDetails FormItems are unregistered until Certification is
|
||||
// opened. onFinish must use getFieldsValue(true) to read store values for
|
||||
// unregistered fields; otherwise save clears certified_by to null.
|
||||
const put = jest.spyOn(SupersetCore.SupersetClient, 'put');
|
||||
put.mockResolvedValue({
|
||||
json: {
|
||||
result: {
|
||||
dashboard_title: 'dashboard_title',
|
||||
slug: 'slug',
|
||||
json_metadata: 'json_metadata',
|
||||
editors: 'editors',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
const propsWithDashboardInfo = {
|
||||
...props,
|
||||
dashboardInfo: {
|
||||
...dashboardInfo,
|
||||
json_metadata: mockedJsonMetadata,
|
||||
},
|
||||
};
|
||||
render(<PropertiesModal {...propsWithDashboardInfo} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
await screen.findByTestId('dashboard-edit-properties-form');
|
||||
|
||||
// Only General information fields are mounted; Certification inputs are not.
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(3);
|
||||
expect(screen.queryByText('Certified by')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const submitCall = props.onSubmit.mock.calls[0][0];
|
||||
expect(submitCall.certifiedBy).toBe('John Doe');
|
||||
expect(submitCall.certificationDetails).toBe('Sample certification');
|
||||
|
||||
expect(put).toHaveBeenCalled();
|
||||
const putRequest = put.mock.calls[0][0];
|
||||
expect(typeof putRequest.body).toBe('string');
|
||||
const putBody = JSON.parse(putRequest.body as string);
|
||||
expect(putBody.certified_by).toBe('John Doe');
|
||||
expect(putBody.certification_details).toBe('Sample certification');
|
||||
});
|
||||
|
||||
test('submitting with onlyApply:true', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
|
||||
@@ -307,7 +307,7 @@ const PropertiesModal = ({
|
||||
slug,
|
||||
certifiedBy,
|
||||
certificationDetails,
|
||||
} = form.getFieldsValue();
|
||||
} = form.getFieldsValue(true);
|
||||
let currentJsonMetadata = jsonMetadata;
|
||||
|
||||
// validate currentJsonMetadata
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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 { DataMaskStateWithId } from '@superset-ui/core';
|
||||
|
||||
// Mock factories must build their own jest.fn()s: jest.mock calls are hoisted
|
||||
// above this file's declarations, so a factory closing over a const would read
|
||||
// it before initialization.
|
||||
jest.mock('@apache-superset/core/utils', () => ({
|
||||
logging: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../views/store', () => ({
|
||||
store: { dispatch: jest.fn(), getState: jest.fn(), subscribe: jest.fn() },
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { embeddedApi } from './api';
|
||||
// eslint-disable-next-line import/first
|
||||
import { updateDataMask } from '../dataMask/actions';
|
||||
|
||||
const { logging: mockLogging } = jest.requireMock(
|
||||
'@apache-superset/core/utils',
|
||||
);
|
||||
const { store: mockStore } = jest.requireMock('../views/store');
|
||||
const mockDispatch = mockStore.dispatch;
|
||||
const mockGetState = mockStore.getState;
|
||||
|
||||
const nativeFilterMask = { filterState: { value: ['CA'] } };
|
||||
const crossFilterMask = { filterState: { value: [2024] } };
|
||||
|
||||
// `dashboardInfo.id` is only set once HYDRATE_DASHBOARD lands, so it doubles as
|
||||
// the "dashboard is hydrated" signal setDataMask waits for.
|
||||
function stateWithFilters(filterIds: string[]) {
|
||||
return {
|
||||
dashboardInfo: { id: 1 },
|
||||
dataMask: Object.fromEntries(filterIds.map(id => [id, { id }])),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('setDataMask dispatches an update for each known filter', () => {
|
||||
mockGetState.mockReturnValue(
|
||||
stateWithFilters(['NATIVE_FILTER-1', 'NATIVE_FILTER-2']),
|
||||
);
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
'NATIVE_FILTER-2': crossFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-2', crossFilterMask),
|
||||
);
|
||||
expect(mockLogging.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setDataMask ignores filter ids the dashboard does not know', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
'NATIVE_FILTER-from-another-dashboard': crossFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
expect(mockLogging.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unknown filter ids'),
|
||||
'NATIVE_FILTER-from-another-dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
test('setDataMask ignores the change-trigger flags observeDataMask emits', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
crossFiltersChanged: false,
|
||||
nativeFiltersChanged: true,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
});
|
||||
|
||||
test('setDataMask dispatches nothing when no filter id is known', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters([]));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogging.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setDataMask queues the mask until the dashboard hydrates', () => {
|
||||
let notifyStoreSubscribers = () => {};
|
||||
mockStore.subscribe.mockImplementation((listener: () => void) => {
|
||||
notifyStoreSubscribers = listener;
|
||||
return jest.fn();
|
||||
});
|
||||
mockGetState.mockReturnValue({ dataMask: {} });
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogging.warn).not.toHaveBeenCalled();
|
||||
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
notifyStoreSubscribers();
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
});
|
||||
@@ -17,12 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { batch } from 'react-redux';
|
||||
import { store } from '../views/store';
|
||||
import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils';
|
||||
import { DashboardChartStates } from '../dashboard/types/chartState';
|
||||
import { hasStatefulCharts } from '../dashboard/util/chartStateConverter';
|
||||
import { getChartDataPayloads as getChartDataPayloadsUtil } from './utils';
|
||||
import { updateDataMask } from '../dataMask/actions';
|
||||
|
||||
const bootstrapData = getBootstrapData();
|
||||
|
||||
@@ -40,6 +43,7 @@ type EmbeddedSupersetApi = {
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
}) => Promise<Record<string, JsonObject>>;
|
||||
setDataMask: ({ dataMask }: { dataMask: DataMaskStateWithId }) => void;
|
||||
};
|
||||
|
||||
const getScrollSize = (): Size => ({
|
||||
@@ -83,6 +87,58 @@ const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
|
||||
|
||||
const getDataMask = () => store?.getState()?.dataMask || {};
|
||||
|
||||
const isDashboardHydrated = () => Boolean(store?.getState()?.dashboardInfo?.id);
|
||||
|
||||
const applyDataMask = (dataMask: DataMaskStateWithId) => {
|
||||
// The dashboard's own data mask holds an entry for every native filter and
|
||||
// every cross-filter-capable chart, so it doubles as the set of filter ids
|
||||
// this dashboard can accept. Anything else — a filter id from a different
|
||||
// dashboard, or the change-trigger flags that `observeDataMask` emits
|
||||
// alongside the mask — would otherwise be inserted as a bogus filter and
|
||||
// treated as a globally scoped filter by the active-filter derivation.
|
||||
const knownFilterIds = new Set(Object.keys(getDataMask()));
|
||||
const entries = Object.entries(dataMask);
|
||||
const applicable = entries.filter(([id]) => knownFilterIds.has(id));
|
||||
const ignored = entries.filter(([id]) => !knownFilterIds.has(id));
|
||||
|
||||
if (ignored.length) {
|
||||
logging.warn(
|
||||
'[superset] setDataMask ignored unknown filter ids:',
|
||||
ignored.map(([id]) => id).join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
applicable.forEach(([filterId, mask]) => {
|
||||
store?.dispatch(updateDataMask(filterId, mask));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// A mask requested before the dashboard hydrates cannot be applied yet: the
|
||||
// store holds no filter entries to validate the ids against, and hydration
|
||||
// would replace anything dispatched in the meantime. Hold the request and
|
||||
// replay it once hydration lands.
|
||||
let queuedDataMask: DataMaskStateWithId | undefined;
|
||||
let unsubscribeFromHydration: (() => void) | undefined;
|
||||
|
||||
const setDataMask = ({ dataMask }: { dataMask: DataMaskStateWithId }) => {
|
||||
if (isDashboardHydrated()) {
|
||||
applyDataMask(dataMask);
|
||||
return;
|
||||
}
|
||||
|
||||
queuedDataMask = { ...queuedDataMask, ...dataMask };
|
||||
unsubscribeFromHydration ??= store?.subscribe(() => {
|
||||
if (!isDashboardHydrated()) return;
|
||||
unsubscribeFromHydration?.();
|
||||
unsubscribeFromHydration = undefined;
|
||||
const pending = queuedDataMask;
|
||||
queuedDataMask = undefined;
|
||||
if (pending) applyDataMask(pending);
|
||||
});
|
||||
};
|
||||
|
||||
const getChartStates = () =>
|
||||
store?.getState()?.dashboardState?.chartStates || {};
|
||||
|
||||
@@ -102,4 +158,5 @@ export const embeddedApi: EmbeddedSupersetApi = {
|
||||
getDataMask,
|
||||
getChartStates,
|
||||
getChartDataPayloads,
|
||||
setDataMask,
|
||||
};
|
||||
|
||||
@@ -298,6 +298,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
||||
Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs);
|
||||
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
|
||||
Switchboard.defineMethod('getChartStates', embeddedApi.getChartStates);
|
||||
Switchboard.defineMethod('setDataMask', embeddedApi.setDataMask);
|
||||
Switchboard.defineMethod(
|
||||
'getChartDataPayloads',
|
||||
embeddedApi.getChartDataPayloads,
|
||||
|
||||
+10
-1
@@ -43,6 +43,10 @@ import path from 'path';
|
||||
|
||||
const chartEndpoint = 'glob:*api/v1/chart/*';
|
||||
|
||||
const EDIT_PROPERTIES_INITIAL_STATE = {
|
||||
explore: { can_overwrite: true, can_add: true },
|
||||
};
|
||||
|
||||
fetchMock.get(chartEndpoint, { json: 'foo' });
|
||||
|
||||
window.featureFlags = {
|
||||
@@ -170,7 +174,10 @@ describe('ExploreChartHeader', () => {
|
||||
|
||||
test('Cancelling changes to the properties should reset previous properties', async () => {
|
||||
const props = createProps();
|
||||
render(<ExploreHeader {...props} />, { useRedux: true });
|
||||
render(<ExploreHeader {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: EDIT_PROPERTIES_INITIAL_STATE,
|
||||
});
|
||||
const newChartName = 'New chart name';
|
||||
const prevChartName = props.sliceName;
|
||||
|
||||
@@ -626,6 +633,7 @@ describe('Additional actions tests', () => {
|
||||
const props = createProps();
|
||||
render(<ExploreHeader {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: EDIT_PROPERTIES_INITIAL_STATE,
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
@@ -720,6 +728,7 @@ describe('Additional actions tests', () => {
|
||||
const props = createProps();
|
||||
render(<ExploreHeader {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: EDIT_PROPERTIES_INITIAL_STATE,
|
||||
});
|
||||
expect(props.actions.redirectSQLLab).toHaveBeenCalledTimes(0);
|
||||
userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
|
||||
@@ -279,6 +279,7 @@ interface ExploreState {
|
||||
chartStates?: Record<number, JsonObject>;
|
||||
can_export_image?: boolean;
|
||||
can_overwrite?: boolean;
|
||||
can_add?: boolean;
|
||||
};
|
||||
common?: {
|
||||
conf?: {
|
||||
@@ -335,17 +336,30 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const canOverwrite = useSelector<ExploreState, boolean>(
|
||||
state => state.explore?.can_overwrite ?? false,
|
||||
);
|
||||
// Mirrors the `can_write` permission on the `Chart` view, the same
|
||||
// permission `ChartRestApi.put` (and `restore_version`) require. An editor
|
||||
// who satisfies `canOverwriteSlice` but lacks it would still be turned away
|
||||
// by the API, so the properties editor stays hidden for them too.
|
||||
const canWriteChart = useSelector<ExploreState, boolean>(
|
||||
state => state.explore?.can_add ?? false,
|
||||
);
|
||||
const user = useSelector<
|
||||
ExploreState,
|
||||
UserWithPermissionsAndRoles | undefined
|
||||
>(state => state.user);
|
||||
// `can_overwrite` alone hides version history on any chart without explicit
|
||||
// editors — every seeded chart — even from admins. Same predicate SaveModal
|
||||
// uses, so a user who can save a chart can also see its history.
|
||||
// `can_overwrite` alone hides version history (and edit-properties) on any
|
||||
// chart without explicit editors — every seeded chart — even from admins.
|
||||
// Same predicate SaveModal uses, so a user who can save a chart can also
|
||||
// see its history and edit its properties.
|
||||
const canModifySlice = useMemo(
|
||||
() => canOverwriteSlice({ slice, user, canOverwrite }),
|
||||
[slice, user, canOverwrite],
|
||||
);
|
||||
// `canModifySlice` alone governs version history, whose own read-only
|
||||
// listing needs no write permission (only its restore action does, and
|
||||
// that's gated server-side). Editing properties, however, always PUTs the
|
||||
// chart, so it additionally needs the write permission above.
|
||||
const canEditProperties = canModifySlice && canWriteChart;
|
||||
|
||||
const dataExportDisabled = !canDownloadCSV;
|
||||
const imageExportDisabled = !canExportImage;
|
||||
@@ -601,7 +615,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const menuItems = [];
|
||||
|
||||
// Edit chart properties
|
||||
if (slice) {
|
||||
if (slice && canEditProperties) {
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.EDIT_PROPERTIES,
|
||||
label: t('Edit chart properties'),
|
||||
@@ -1084,6 +1098,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}, [
|
||||
addDangerToast,
|
||||
canDownloadCSV,
|
||||
canEditProperties,
|
||||
canModifySlice,
|
||||
copyLink,
|
||||
dashboards,
|
||||
|
||||
+68
-1
@@ -27,6 +27,7 @@ import {
|
||||
getExportScreenshotMenuItems,
|
||||
} from './index';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
__esModule: true,
|
||||
@@ -74,13 +75,22 @@ jest.mock('@superset-ui/core', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
common: {
|
||||
user_subjects: [1],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
latestQueryFormData: {
|
||||
datasource: '1__table',
|
||||
viz_type: 'pivot_table_v2',
|
||||
},
|
||||
canDownloadCSV: true,
|
||||
slice: { slice_id: 1, slice_name: 'Test Chart' },
|
||||
slice: { slice_id: 1, slice_name: 'Test Chart' } as unknown as Slice,
|
||||
ownState: {},
|
||||
dashboards: [],
|
||||
onOpenInEditor: jest.fn(),
|
||||
@@ -113,6 +123,63 @@ beforeEach(() => {
|
||||
mockExportChart.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('hides Edit chart properties from a user who is not an owner/editor of the chart (regression #38884)', async () => {
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
slice={
|
||||
{
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
editors: [2],
|
||||
} as unknown as Slice
|
||||
}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows Edit chart properties for a chart editor with chart write permission', async () => {
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
slice={
|
||||
{
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
editors: [1],
|
||||
} as unknown as Slice
|
||||
}
|
||||
/>,
|
||||
{ useRedux: true, initialState: { explore: { can_add: true } } },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit chart properties')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides Edit chart properties from a chart editor lacking chart write permission', async () => {
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
slice={
|
||||
{
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
editors: [1],
|
||||
} as unknown as Slice
|
||||
}
|
||||
/>,
|
||||
{ useRedux: true, initialState: { explore: { can_add: false } } },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows 413 error toast when exportCSV fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
|
||||
@@ -738,4 +738,14 @@ const smp = new SpeedMeasurePlugin({
|
||||
disable: !measure,
|
||||
});
|
||||
|
||||
// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'`
|
||||
// above omits both). Not `normal`/`detailed` stats: those also serialize the
|
||||
// full ~15k-module dependency graph, which is hundreds of MB for this app --
|
||||
// large enough to exceed Node's max string length when read back with
|
||||
// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI.
|
||||
// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json
|
||||
if (process.env.BUNDLE_SIZE_STATS) {
|
||||
config.stats = { all: false, assets: true, entrypoints: true };
|
||||
}
|
||||
|
||||
module.exports = smp.wrap(config);
|
||||
|
||||
@@ -22,7 +22,7 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
from flask import current_app as app, g, make_response, request, Response
|
||||
from flask import current_app as app, make_response, request, Response
|
||||
from flask_appbuilder.api import expose, protect
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import ValidationError
|
||||
@@ -37,6 +37,7 @@ from superset.charts.data.dashboard_filter_context import (
|
||||
DashboardFilterContext,
|
||||
get_dashboard_filter_context,
|
||||
)
|
||||
from superset.charts.data.form_data import set_form_data
|
||||
from superset.charts.data.query_context_cache_loader import QueryContextCacheLoader
|
||||
from superset.charts.schemas import ChartDataQueryContextSchema
|
||||
from superset.commands.chart.data.create_async_job_command import (
|
||||
@@ -214,7 +215,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
# templating pulls form data from the request globally, so this
|
||||
# fallback ensures it has the filters and extra_form_data applied
|
||||
# when used in get_sqla_query which constructs the final query.
|
||||
g.form_data = json_body
|
||||
set_form_data(json_body)
|
||||
|
||||
try:
|
||||
query_context = self._create_query_context_from_form(json_body)
|
||||
@@ -410,7 +411,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
cached_data = self._load_query_context_form_from_cache(cache_key)
|
||||
# Set form_data in Flask Global as it is used as a fallback
|
||||
# for async queries with jinja context
|
||||
g.form_data = cached_data
|
||||
set_form_data(cached_data)
|
||||
query_context = self._create_query_context_from_form(cached_data)
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from flask import g
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.common.query_context import QueryContext
|
||||
from superset.common.query_object import QueryObject
|
||||
|
||||
|
||||
def set_form_data(form_data: dict[str, Any]) -> None:
|
||||
"""Expose chart data request fields to Jinja template macros."""
|
||||
g.form_data = form_data
|
||||
|
||||
|
||||
def _serialize_query(
|
||||
query: QueryObject,
|
||||
form_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Serialize query fields consumed by the Jinja form-data fallback."""
|
||||
query_data = dict(query.to_dict())
|
||||
query_data["filters"] = query.filter
|
||||
if query.time_range is not None:
|
||||
query_data["time_range"] = query.time_range
|
||||
if url_params := form_data.get("url_params"):
|
||||
query_data["url_params"] = url_params
|
||||
return query_data
|
||||
|
||||
|
||||
def set_query_context_form_data(
|
||||
query_context: QueryContext,
|
||||
datasource_id: int,
|
||||
datasource_type: str,
|
||||
) -> None:
|
||||
"""Expose a programmatically-created query like a chart data API request."""
|
||||
form_data = query_context.form_data or {}
|
||||
set_form_data(
|
||||
{
|
||||
"datasource": {"id": datasource_id, "type": datasource_type},
|
||||
"queries": [
|
||||
_serialize_query(query, form_data) for query in query_context.queries
|
||||
],
|
||||
"form_data": form_data,
|
||||
}
|
||||
)
|
||||
@@ -21,6 +21,7 @@ from functools import partial
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
from superset import db
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import DatabaseNotFoundError
|
||||
from superset.daos.database import DatabaseUserOAuth2TokensDAO
|
||||
@@ -96,6 +97,11 @@ class OAuth2StoreTokenCommand(BaseCommand):
|
||||
database_id=self._state["database_id"],
|
||||
):
|
||||
DatabaseUserOAuth2TokensDAO.delete([existing])
|
||||
# flush the delete before inserting the replacement -- the unit
|
||||
# of work otherwise emits INSERTs before DELETEs within a single
|
||||
# flush, which would trip the (user_id, database_id) unique
|
||||
# index below on the old row.
|
||||
db.session.flush()
|
||||
|
||||
# store tokens
|
||||
expiration = datetime.now() + timedelta(seconds=token_response["expires_in"])
|
||||
|
||||
@@ -247,6 +247,20 @@ def load_configs(
|
||||
)
|
||||
exc.messages = {file_name: exc.messages}
|
||||
exceptions.append(exc)
|
||||
except json.JSONDecodeError as exc:
|
||||
# masked_encrypted_extra comes straight from the imported YAML
|
||||
# (before schema validation) and may not be valid JSON. Convert
|
||||
# the raw decode error into a ValidationError so it flows into
|
||||
# the aggregated CommandInvalidError like every other per-file
|
||||
# validation failure, instead of escaping as an opaque 500.
|
||||
logger.error(
|
||||
"Invalid JSON in masked_encrypted_extra for %s: %s",
|
||||
file_name,
|
||||
exc,
|
||||
)
|
||||
exceptions.append(
|
||||
ValidationError({file_name: {"masked_encrypted_extra": [str(exc)]}})
|
||||
)
|
||||
|
||||
return configs
|
||||
|
||||
@@ -318,27 +332,39 @@ def import_tag(
|
||||
|
||||
for tag_name in target_tag_names:
|
||||
try:
|
||||
tag = existing_tags.get(tag_name)
|
||||
# Isolate each tag operation in a SAVEPOINT so a failure (e.g. a
|
||||
# concurrent unique-constraint violation) rolls back only the failed
|
||||
# tag and leaves the session usable for the remaining tags, instead
|
||||
# of poisoning the session with a pending-rollback state.
|
||||
with db_session.begin_nested():
|
||||
tag = existing_tags.get(tag_name)
|
||||
|
||||
# If tag does not exist, create it
|
||||
if tag is None:
|
||||
description = tag_descriptions.get(tag_name, None)
|
||||
tag = Tag(name=tag_name, description=description, type="custom")
|
||||
db_session.add(tag)
|
||||
existing_tags[tag_name] = tag # Update the existing_tags dictionary
|
||||
# If tag does not exist, create it
|
||||
if tag is None:
|
||||
description = tag_descriptions.get(tag_name, None)
|
||||
tag = Tag(name=tag_name, description=description, type="custom")
|
||||
db_session.add(tag)
|
||||
existing_tags[tag_name] = tag # Update the existing_tags dictionary
|
||||
|
||||
# Ensure the association with the object
|
||||
tagged_object = (
|
||||
db_session.query(TaggedObject)
|
||||
.filter_by(object_id=object_id, object_type=object_type, tag_id=tag.id)
|
||||
.first()
|
||||
)
|
||||
if not tagged_object:
|
||||
new_tagged_object = TaggedObject(
|
||||
tag_id=tag.id, object_id=object_id, object_type=object_type
|
||||
# Ensure the association with the object
|
||||
tagged_object = (
|
||||
db_session.query(TaggedObject)
|
||||
.filter_by(
|
||||
object_id=object_id, object_type=object_type, tag_id=tag.id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
db_session.add(new_tagged_object)
|
||||
if not tagged_object:
|
||||
new_tagged_object = TaggedObject(
|
||||
tag_id=tag.id, object_id=object_id, object_type=object_type
|
||||
)
|
||||
db_session.add(new_tagged_object)
|
||||
|
||||
# Only record the tag as imported once the SAVEPOINT has been
|
||||
# released (and its pending inserts flushed) without error; the
|
||||
# nested block's own flush can still fail on a concurrent
|
||||
# unique-constraint violation, in which case this line must not
|
||||
# run.
|
||||
new_tag_ids.append(tag.id)
|
||||
|
||||
except SQLAlchemyError as err:
|
||||
@@ -349,7 +375,9 @@ def import_tag(
|
||||
object_id,
|
||||
err,
|
||||
)
|
||||
continue # No need for manual rollback, handled by transaction decorator
|
||||
# The SAVEPOINT was rolled back by begin_nested(); the session is
|
||||
# still usable for the remaining tags.
|
||||
continue
|
||||
|
||||
# Remove old tags not in the new config
|
||||
for tag in existing_assocs:
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any, cast
|
||||
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import db, results_backend, results_backend_use_msgpack
|
||||
from superset.commands.base import BaseCommand
|
||||
@@ -98,6 +99,15 @@ class SqlExecutionResultsCommand(BaseCommand):
|
||||
),
|
||||
status=403,
|
||||
) from ex
|
||||
except TemplateError as ex:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=str(ex),
|
||||
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
status=400,
|
||||
) from ex
|
||||
|
||||
# Now fetch results from backend (query exists, so this is a valid request)
|
||||
read_from_results_backend_start = now_as_float()
|
||||
|
||||
@@ -34,11 +34,12 @@ from superset.commands.dashboard.exceptions import (
|
||||
DashboardUpdateFailedError,
|
||||
)
|
||||
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
|
||||
from superset.dashboards.filters import DashboardAccessFilter, is_uuid
|
||||
from superset.dashboards.filter_scope import derive_metadata_scopes
|
||||
from superset.dashboards.filters import DashboardAccessFilter
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
from superset.models.core import FavStar, FavStarClassName
|
||||
from superset.models.dashboard import Dashboard, id_or_slug_filter
|
||||
from superset.models.dashboard import Dashboard, id_or_slug_filter, is_uuid
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.models.helpers import skip_visibility_filter
|
||||
from superset.models.slice import Slice
|
||||
@@ -547,7 +548,9 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
cls, id: str
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
dashboard = cls.get_by_id_or_slug(id)
|
||||
metadata = json.loads(dashboard.json_metadata or "{}")
|
||||
metadata = derive_metadata_scopes(
|
||||
dashboard, json.loads(dashboard.json_metadata or "{}")
|
||||
)
|
||||
native_filter_configuration = metadata.get("native_filter_configuration", [])
|
||||
|
||||
tab_filters = defaultdict(list)
|
||||
@@ -617,6 +620,13 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
metadata["native_filter_configuration"] = updated_configuration
|
||||
dashboard.json_metadata = json.dumps(metadata)
|
||||
|
||||
# The client rebuilds its in-scope state from this response, so hand
|
||||
# back derived scopes rather than the stored caches, which are stale
|
||||
# for every filter the caller did not touch.
|
||||
updated_configuration = derive_metadata_scopes(dashboard, metadata)[
|
||||
"native_filter_configuration"
|
||||
]
|
||||
|
||||
return updated_configuration
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -91,6 +91,7 @@ from superset.commands.importers.v1.utils import get_contents_from_bundle
|
||||
from superset.commands.purge import PurgeArchivedCommand, SoftDeleteBinding
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
|
||||
from superset.dashboards.filter_scope import derive_json_metadata
|
||||
from superset.dashboards.filters import (
|
||||
DashboardAccessFilter,
|
||||
DashboardCertifiedFilter,
|
||||
@@ -653,6 +654,12 @@ class DashboardRestApi(
|
||||
schema = self.dashboard_get_response_schema
|
||||
|
||||
result = schema.dump(dash)
|
||||
if json_metadata := result.get("json_metadata"):
|
||||
# The stored scope caches (``chartsInScope``, ``tabsInScope``,
|
||||
# ``chart_configuration``) go stale as soon as the layout changes;
|
||||
# derive them so callers see the same document the dashboard client
|
||||
# computes for itself.
|
||||
result["json_metadata"] = derive_json_metadata(dash, json_metadata)
|
||||
if "charts" in result:
|
||||
# Only name the member charts the caller can access, consistent with
|
||||
# the per-object narrowing applied to the dashboard's datasets and
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# 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.
|
||||
"""Derive filter-scope caches in ``json_metadata`` from the dashboard layout.
|
||||
|
||||
``chartsInScope`` / ``tabsInScope`` on a native filter, and the ``chartsInScope``
|
||||
lists inside ``chart_configuration`` / ``global_chart_configuration``, are
|
||||
denormalized caches of the authoritative ``scope`` plus ``position_json``. They
|
||||
are written when a dashboard is saved and are never revisited afterwards, so a
|
||||
dashboard that has charts added or removed - or that was seeded, exported or
|
||||
imported - carries scope arrays naming charts it does not contain.
|
||||
|
||||
The dashboard client already ignores the stored values and recomputes them from
|
||||
the live layout on every load, which is why the JSON Metadata panel and
|
||||
``GET /api/v1/dashboard/{id}`` disagreed on a dashboard nobody had ever saved.
|
||||
Deriving them on read makes the API agree with the client and keeps integrations
|
||||
that read ``native_filter_configuration`` from receiving dangling chart ids.
|
||||
|
||||
The rules mirror the client (``superset-frontend/src/dashboard/util``):
|
||||
``calculateScopes``, ``getChartIdsInFilterScope``, ``findTabsWithChartsInScope``
|
||||
and ``getCrossFiltersConfiguration``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from superset.utils import json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
CHART_TYPE = "CHART"
|
||||
TAB_TYPE = "TAB"
|
||||
NATIVE_FILTER_DIVIDER_PREFIX = "NATIVE_FILTER_DIVIDER-"
|
||||
DIVIDER_TYPES = frozenset({"DIVIDER", "CHART_CUSTOMIZATION_DIVIDER"})
|
||||
|
||||
# ``chart-<chartId>-layer-<layerIndex>``, the per-layer scope keys a deck.gl
|
||||
# multi-layer chart contributes to ``scope.selectedLayers``.
|
||||
LAYER_SELECTION_RE = re.compile(r"^chart-(\d+)-layer-(\d+)$")
|
||||
|
||||
ChartLayoutItems = dict[int, list[dict[str, Any]]]
|
||||
|
||||
|
||||
def build_chart_layout_items(position_data: dict[str, Any]) -> ChartLayoutItems:
|
||||
"""Map each chart id in the layout to the layout items that render it."""
|
||||
chart_layout_items: ChartLayoutItems = {}
|
||||
for item in position_data.values():
|
||||
if not isinstance(item, dict) or item.get("type") != CHART_TYPE:
|
||||
continue
|
||||
chart_id = item.get("meta", {}).get("chartId")
|
||||
if isinstance(chart_id, int):
|
||||
chart_layout_items.setdefault(chart_id, []).append(item)
|
||||
return chart_layout_items
|
||||
|
||||
|
||||
def get_chart_ids_in_scope(
|
||||
scope: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[int]:
|
||||
"""Charts covered by ``scope``, in ``chart_ids`` order."""
|
||||
excluded = set(scope.get("excluded") or [])
|
||||
root_path = set(scope.get("rootPath") or [])
|
||||
|
||||
def in_scope(chart_id: int) -> bool:
|
||||
if chart_id in excluded:
|
||||
return False
|
||||
return any(
|
||||
parent in root_path
|
||||
for layout_item in chart_layout_items.get(chart_id, [])
|
||||
for parent in layout_item.get("parents") or []
|
||||
)
|
||||
|
||||
selected_layers = scope.get("selectedLayers") or []
|
||||
if not selected_layers:
|
||||
return [chart_id for chart_id in chart_ids if in_scope(chart_id)]
|
||||
|
||||
# A layer selection targets its chart directly, and suppresses the
|
||||
# rootPath/excluded test for that chart.
|
||||
charts_with_layer_selections = set()
|
||||
targeted: list[int] = []
|
||||
chart_id_set = set(chart_ids)
|
||||
for selection_key in selected_layers:
|
||||
if match := LAYER_SELECTION_RE.match(str(selection_key)):
|
||||
chart_id = int(match.group(1))
|
||||
charts_with_layer_selections.add(chart_id)
|
||||
if chart_id in chart_id_set and chart_id not in targeted:
|
||||
targeted.append(chart_id)
|
||||
|
||||
return targeted + [
|
||||
chart_id
|
||||
for chart_id in chart_ids
|
||||
if chart_id not in charts_with_layer_selections
|
||||
and chart_id not in targeted
|
||||
and in_scope(chart_id)
|
||||
]
|
||||
|
||||
|
||||
def get_tabs_in_scope(
|
||||
charts_in_scope: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[str]:
|
||||
"""Tabs holding at least one of ``charts_in_scope``."""
|
||||
tabs_in_scope: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for chart_id in charts_in_scope:
|
||||
for layout_item in chart_layout_items.get(chart_id, []):
|
||||
for parent in layout_item.get("parents") or []:
|
||||
if parent.startswith(f"{TAB_TYPE}-") and parent not in seen:
|
||||
seen.add(parent)
|
||||
tabs_in_scope.append(parent)
|
||||
return tabs_in_scope
|
||||
|
||||
|
||||
def _is_divider(item: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(item.get("id", "")).startswith(NATIVE_FILTER_DIVIDER_PREFIX)
|
||||
or item.get("type") in DIVIDER_TYPES
|
||||
)
|
||||
|
||||
|
||||
def _derive_item_scopes(
|
||||
items: list[Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[Any]:
|
||||
"""Restamp ``chartsInScope`` / ``tabsInScope`` on scoped config items.
|
||||
|
||||
Items without a usable ``scope`` are returned untouched: legacy chart
|
||||
customizations target a chart directly and only gain a ``scope`` once the
|
||||
client migrates them, so overwriting their cache here would drop targeting
|
||||
the client still needs.
|
||||
"""
|
||||
derived = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
derived.append(item)
|
||||
continue
|
||||
if _is_divider(item):
|
||||
derived.append({**item, "chartsInScope": [], "tabsInScope": []})
|
||||
continue
|
||||
scope = item.get("scope")
|
||||
if not isinstance(scope, dict) or not isinstance(scope.get("excluded"), list):
|
||||
derived.append(item)
|
||||
continue
|
||||
charts_in_scope = get_chart_ids_in_scope(scope, chart_ids, chart_layout_items)
|
||||
derived.append(
|
||||
{
|
||||
**item,
|
||||
"chartsInScope": charts_in_scope,
|
||||
"tabsInScope": get_tabs_in_scope(charts_in_scope, chart_layout_items),
|
||||
}
|
||||
)
|
||||
return derived
|
||||
|
||||
|
||||
def _derive_cross_filter_scopes(
|
||||
metadata: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> None:
|
||||
global_config = metadata.get("global_chart_configuration")
|
||||
global_charts_in_scope = chart_ids
|
||||
if isinstance(global_config, dict) and isinstance(global_config.get("scope"), dict):
|
||||
global_charts_in_scope = get_chart_ids_in_scope(
|
||||
global_config["scope"], chart_ids, chart_layout_items
|
||||
)
|
||||
metadata["global_chart_configuration"] = {
|
||||
**global_config,
|
||||
"chartsInScope": global_charts_in_scope,
|
||||
}
|
||||
|
||||
chart_configuration = metadata.get("chart_configuration")
|
||||
if not isinstance(chart_configuration, dict):
|
||||
return
|
||||
|
||||
derived_configuration = {}
|
||||
for key, config in chart_configuration.items():
|
||||
try:
|
||||
chart_id = int(key)
|
||||
except (TypeError, ValueError):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
# Config for a chart no longer on the dashboard is dead weight; the
|
||||
# client drops it on load for the same reason.
|
||||
if chart_id not in chart_layout_items:
|
||||
continue
|
||||
if not isinstance(config, dict):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
cross_filters = config.get("crossFilters")
|
||||
if not isinstance(cross_filters, dict):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
scope = cross_filters.get("scope")
|
||||
if isinstance(scope, dict):
|
||||
charts_in_scope = get_chart_ids_in_scope(
|
||||
scope, chart_ids, chart_layout_items
|
||||
)
|
||||
else:
|
||||
# Anything that is not an explicit scope object points at the
|
||||
# dashboard-wide scope, which never includes the emitting chart.
|
||||
charts_in_scope = [cid for cid in global_charts_in_scope if cid != chart_id]
|
||||
derived_configuration[key] = {
|
||||
**config,
|
||||
"crossFilters": {**cross_filters, "chartsInScope": charts_in_scope},
|
||||
}
|
||||
|
||||
metadata["chart_configuration"] = derived_configuration
|
||||
|
||||
|
||||
def derive_scopes(
|
||||
metadata: dict[str, Any],
|
||||
position_data: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
) -> dict[str, Any]:
|
||||
"""Return ``metadata`` with every derived scope cache recomputed.
|
||||
|
||||
``chart_ids`` orders the resulting ``chartsInScope`` arrays and should be the
|
||||
dashboard's chart ids as the client sees them, so that the API and the JSON
|
||||
Metadata panel produce byte-identical documents.
|
||||
"""
|
||||
derived = dict(metadata)
|
||||
chart_layout_items = build_chart_layout_items(position_data)
|
||||
|
||||
for key in ("native_filter_configuration", "chart_customization_config"):
|
||||
config = derived.get(key)
|
||||
if isinstance(config, list):
|
||||
derived[key] = _derive_item_scopes(config, chart_ids, chart_layout_items)
|
||||
|
||||
_derive_cross_filter_scopes(derived, chart_ids, chart_layout_items)
|
||||
return derived
|
||||
|
||||
|
||||
def derive_metadata_scopes(
|
||||
dashboard: Dashboard,
|
||||
metadata: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""``derive_scopes`` for a dashboard model's parsed ``json_metadata``."""
|
||||
return derive_scopes(
|
||||
metadata,
|
||||
dashboard.position,
|
||||
[slc.id for slc in dashboard.slices],
|
||||
)
|
||||
|
||||
|
||||
def derive_json_metadata(dashboard: Dashboard, json_metadata: str) -> str:
|
||||
"""``derive_metadata_scopes`` over a raw ``json_metadata`` string.
|
||||
|
||||
Metadata that does not parse as a JSON object is handed back untouched -
|
||||
reading a dashboard is not the place to start rejecting documents that have
|
||||
always been served as-is.
|
||||
"""
|
||||
try:
|
||||
metadata = json.loads(json_metadata)
|
||||
except (TypeError, ValueError):
|
||||
return json_metadata
|
||||
if not isinstance(metadata, dict):
|
||||
return json_metadata
|
||||
return json.dumps(derive_metadata_scopes(dashboard, metadata))
|
||||
+18
-101
@@ -16,26 +16,26 @@
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app, g
|
||||
from flask import current_app
|
||||
from flask_babel import lazy_gettext as _
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm.query import Query
|
||||
|
||||
from superset import db, is_feature_enabled, security_manager
|
||||
from superset import db, security_manager
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
from superset.models.dashboard import Dashboard, is_uuid
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.guest_token import GuestTokenResourceType, GuestUser
|
||||
from superset.subjects.filters import (
|
||||
EditableFilter,
|
||||
subject_relation_exists_for_current_user,
|
||||
)
|
||||
from superset.subjects.models import dashboard_editors, dashboard_viewers
|
||||
from superset.tags.filters import BaseTagIdFilter, BaseTagNameFilter
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
from superset.utils.filters import (
|
||||
get_dataset_access_filters,
|
||||
guest_embedded_dashboard_filter,
|
||||
)
|
||||
from superset.views.base import BaseFilter
|
||||
from superset.views.base_api import BaseFavoriteFilter
|
||||
from superset.views.filters import BaseDeletedRecencyFilter, BaseDeletedStateFilter
|
||||
@@ -110,20 +110,20 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
|
||||
"""
|
||||
List dashboards with the following criteria:
|
||||
|
||||
When ``ENABLE_VIEWERS`` is on:
|
||||
1. Those where the user is an editor (published or not)
|
||||
2. Those where the user is a viewer (published only)
|
||||
3. Those with no viewers → fall back to dataset-based access (published only)
|
||||
4. Embedded dashboard access (preserved as-is)
|
||||
|
||||
When ``ENABLE_VIEWERS`` is off (legacy):
|
||||
1. Those which the user is an editor of
|
||||
2. Those which have been published (if they have access to at least one slice)
|
||||
|
||||
If the user is an admin then show all dashboards.
|
||||
1. Embedded guests: only the dashboards in their token, nothing else
|
||||
2. Admins: all dashboards
|
||||
3. Editors: their dashboards (published or not)
|
||||
4. Viewers: published dashboards
|
||||
5. Dashboards with no viewers → fall back to dataset-based access
|
||||
(published only)
|
||||
"""
|
||||
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
# Guests are scoped to their token's dashboards only, never widened by
|
||||
# the role paths below (mirrors ChartFilter).
|
||||
if (guest_condition := guest_embedded_dashboard_filter()) is not None:
|
||||
return query.filter(guest_condition)
|
||||
|
||||
if security_manager.is_admin():
|
||||
return query
|
||||
|
||||
@@ -192,91 +192,8 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
|
||||
if user_id:
|
||||
filters.append(Dashboard.id.in_(extra_dashboards_filter(user_id)))
|
||||
|
||||
# (D) Embedded: preserved as-is
|
||||
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
|
||||
g.user
|
||||
):
|
||||
guest_user: GuestUser = g.user
|
||||
embedded_dashboard_ids = [
|
||||
r["id"]
|
||||
for r in guest_user.resources
|
||||
if r["type"] == GuestTokenResourceType.DASHBOARD.value
|
||||
]
|
||||
condition = (
|
||||
Dashboard.embedded.any(
|
||||
EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
|
||||
)
|
||||
if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
|
||||
else Dashboard.id.in_(embedded_dashboard_ids)
|
||||
)
|
||||
filters.append(condition)
|
||||
|
||||
return query.filter(or_(*filters)) if filters else query
|
||||
|
||||
def _apply_legacy(self, query: Query) -> Query:
|
||||
datasource_perm_query = (
|
||||
db.session.query(Dashboard.id)
|
||||
.join(Dashboard.slices, isouter=True)
|
||||
.join(SqlaTable, Slice.datasource_id == SqlaTable.id)
|
||||
.join(Database, SqlaTable.database_id == Database.id)
|
||||
.filter(
|
||||
and_(
|
||||
Dashboard.published.is_(True),
|
||||
get_dataset_access_filters(
|
||||
Slice,
|
||||
security_manager.can_access_all_datasources(),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Editors query
|
||||
editor_ids_query = db.session.query(dashboard_editors.c.dashboard_id).filter(
|
||||
subject_relation_exists_for_current_user(dashboard_editors)
|
||||
)
|
||||
|
||||
feature_flagged_filters = []
|
||||
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
|
||||
g.user
|
||||
):
|
||||
guest_user: GuestUser = g.user
|
||||
embedded_dashboard_ids = [
|
||||
r["id"]
|
||||
for r in guest_user.resources
|
||||
if r["type"] == GuestTokenResourceType.DASHBOARD.value
|
||||
]
|
||||
|
||||
# TODO (embedded): only use uuid filter once uuids are rolled out
|
||||
condition = (
|
||||
Dashboard.embedded.any(
|
||||
EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
|
||||
)
|
||||
if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
|
||||
else Dashboard.id.in_(embedded_dashboard_ids)
|
||||
)
|
||||
|
||||
feature_flagged_filters.append(condition)
|
||||
|
||||
extra_access_filters = []
|
||||
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
|
||||
if extra_dashboards_filter := extra_filters.get("dashboards"):
|
||||
user_id = get_user_id()
|
||||
if user_id:
|
||||
extra_access_filters.append(
|
||||
Dashboard.id.in_(extra_dashboards_filter(user_id))
|
||||
)
|
||||
|
||||
query = query.filter(
|
||||
or_(
|
||||
Dashboard.id.in_(editor_ids_query),
|
||||
Dashboard.id.in_(datasource_perm_query),
|
||||
*feature_flagged_filters,
|
||||
*extra_access_filters,
|
||||
)
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
class DashboardEditableFilter(EditableFilter): # pylint: disable=too-few-public-methods
|
||||
"""Filter for dashboards the user can edit."""
|
||||
|
||||
@@ -41,27 +41,44 @@ params:
|
||||
filterOptionName: 2745eae5
|
||||
operator: NOT IN
|
||||
subject: country_code
|
||||
compare_lag: '10'
|
||||
compare_suffix: o10Y
|
||||
country_fieldtype: cca3
|
||||
color_scheme: supersetColors
|
||||
entity: country_name
|
||||
granularity_sqla: year
|
||||
groupby: []
|
||||
limit: 0
|
||||
markup_type: markdown
|
||||
legendOrientation: top
|
||||
legendType: scroll
|
||||
max_bubble_size: '50'
|
||||
row_limit: 50000
|
||||
opacity: 0.6
|
||||
order_desc: true
|
||||
row_limit: 500
|
||||
series: region
|
||||
show_bubbles: true
|
||||
since: '2011-01-01'
|
||||
size: sum__SP_POP_TOTL
|
||||
time_range: '2014-01-01 : 2014-01-02'
|
||||
until: '2011-01-02'
|
||||
viz_type: bubble
|
||||
x: sum__SP_RUR_TOTL_ZS
|
||||
y: sum__SP_DYN_LE00_IN
|
||||
show_legend: true
|
||||
size:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_POP_TOTL
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_POP_TOTL)
|
||||
optionName: metric_size_life_expectancy_vs_rural
|
||||
time_range: '2011-01-01 : 2011-01-02'
|
||||
tooltipSizeFormat: SMART_NUMBER
|
||||
truncateXAxis: true
|
||||
viz_type: bubble_v2
|
||||
x:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_RUR_TOTL_ZS
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_RUR_TOTL_ZS)
|
||||
optionName: metric_x_life_expectancy_vs_rural
|
||||
y:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_DYN_LE00_IN
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_DYN_LE00_IN)
|
||||
optionName: metric_y_life_expectancy_vs_rural
|
||||
query_context: null
|
||||
slice_name: Life Expectancy VS Rural %
|
||||
uuid: c18faec9-ec43-4d36-8b66-4c8b1372020f
|
||||
version: 1.0.0
|
||||
viz_type: bubble
|
||||
viz_type: bubble_v2
|
||||
|
||||
@@ -46,6 +46,7 @@ Configuration:
|
||||
|
||||
import logging
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Callable, cast, Generator, TYPE_CHECKING, TypeAlias, TypeVar
|
||||
|
||||
from flask import current_app, g, has_app_context, has_request_context
|
||||
@@ -60,6 +61,7 @@ from superset.mcp_service.guest_token_verifier import GUEST_TOKEN_CLAIM
|
||||
from superset.mcp_service.mcp_config import (
|
||||
default_user_resolver,
|
||||
get_mcp_api_key_enabled,
|
||||
MCP_GUEST_ALLOWED_TOOLS,
|
||||
validate_multi_issuer_user_resolver,
|
||||
)
|
||||
from superset.mcp_service.session_scope import _mcp_session_token
|
||||
@@ -78,6 +80,15 @@ F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The resolved user's id for the current tool call, set by
|
||||
# _setup_user_context(). g.user is only valid for the lifetime of the
|
||||
# per-call app context pushed by _get_app_context_manager() (see its
|
||||
# docstring) and is gone by the time LoggingMiddleware's on_call_tool/
|
||||
# on_message finally-blocks run after call_next() returns. This ContextVar
|
||||
# survives that context pop (mirrors _mcp_call_id_var in middleware.py) so
|
||||
# audit logging can attribute the call to the right user.
|
||||
_mcp_user_id_var: ContextVar[int | None] = ContextVar("mcp_user_id", default=None)
|
||||
|
||||
# An MCP request resolves to a real DB ``User`` or, for embedded guests, a
|
||||
# ``GuestUser`` (an AnonymousUserMixin, not a ``User`` subclass). Both are valid
|
||||
# authenticated principals for tool execution.
|
||||
@@ -244,21 +255,9 @@ def _log_scope_denial(
|
||||
|
||||
# Default-deny allow-list for embedded guests: a guest may call only these tools,
|
||||
# regardless of MCP_RBAC_ENABLED or how the guest role (PUBLIC_ROLE_LIKE) is
|
||||
# configured. Everything else is denied, including newly added tools until listed
|
||||
# here. Sync with mcp_config.py.
|
||||
_DEFAULT_GUEST_ALLOWED_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
# Dashboard structure, scoped to the token's embedded dashboards.
|
||||
"get_dashboard_info",
|
||||
"get_dashboard_layout",
|
||||
"list_dashboards",
|
||||
# Chart read + data, scoped by ChartFilter; data-model fields redacted.
|
||||
"list_charts",
|
||||
"get_chart_info",
|
||||
"get_chart_data",
|
||||
"get_chart_preview",
|
||||
}
|
||||
)
|
||||
# configured. Everything else is denied, including newly added tools until listed.
|
||||
# Single source of truth: MCP_GUEST_ALLOWED_TOOLS in mcp_config.py.
|
||||
_DEFAULT_GUEST_ALLOWED_TOOLS: frozenset[str] = frozenset(MCP_GUEST_ALLOWED_TOOLS)
|
||||
|
||||
|
||||
def _guest_allowed_tools() -> frozenset[str]:
|
||||
@@ -890,9 +889,9 @@ def _assert_user_active(user: MCPUser | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _setup_user_context() -> MCPUser | None:
|
||||
def _resolve_user_with_retry() -> MCPUser | None:
|
||||
"""
|
||||
Set up user context for MCP tool execution.
|
||||
Resolve the current user, retrying once on a stale DB connection.
|
||||
|
||||
Includes retry logic for stale database connections (e.g., SSL dropped
|
||||
by proxy/load balancer after idle periods). On OperationalError, the
|
||||
@@ -901,14 +900,6 @@ def _setup_user_context() -> MCPUser | None:
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
# Clear stale g.user to prevent user impersonation across
|
||||
# tool calls when no per-request middleware refreshes it.
|
||||
# Only clear in app-context-only mode; preserve g.user when
|
||||
# a request context is active (external middleware set it).
|
||||
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
user = None # Ensure defined before loop in case of unexpected exit
|
||||
@@ -925,7 +916,7 @@ def _setup_user_context() -> MCPUser | None:
|
||||
if hasattr(user, "groups"):
|
||||
user_groups = user.groups # noqa: F841
|
||||
|
||||
break
|
||||
return user
|
||||
except RuntimeError as e:
|
||||
# No Flask application context (e.g., prompts before middleware runs)
|
||||
if "application context" in str(e):
|
||||
@@ -959,8 +950,38 @@ def _setup_user_context() -> MCPUser | None:
|
||||
g.pop("user", None)
|
||||
raise
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _setup_user_context() -> MCPUser | None:
|
||||
"""
|
||||
Set up user context for MCP tool execution.
|
||||
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
# Clear stale g.user to prevent user impersonation across
|
||||
# tool calls when no per-request middleware refreshes it.
|
||||
# Only clear in app-context-only mode; preserve g.user when
|
||||
# a request context is active (external middleware set it).
|
||||
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
# Clear any user_id left over from a previous call in this context
|
||||
# (e.g. sequential calls sharing one asyncio task) so a failed/
|
||||
# unauthenticated lookup below doesn't inherit a stale value.
|
||||
_mcp_user_id_var.set(None)
|
||||
|
||||
user = _resolve_user_with_retry()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
_assert_user_active(user)
|
||||
g.user = user
|
||||
# GuestUser (embedded auth) has no numeric id; leave the ContextVar
|
||||
# cleared (already reset above) rather than raise.
|
||||
if (user_id := getattr(user, "id", None)) is not None:
|
||||
_mcp_user_id_var.set(user_id)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
BigNumberChartConfig,
|
||||
BoxPlotChartConfig,
|
||||
ChartCapabilities,
|
||||
ChartConfig,
|
||||
ChartSemantics,
|
||||
ColumnRef,
|
||||
CurrencyFormat,
|
||||
@@ -52,12 +53,17 @@ from superset.mcp_service.chart.schemas import (
|
||||
WaterfallChartConfig,
|
||||
XYChartConfig,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.dataset_validator import (
|
||||
is_dataset_column_temporal,
|
||||
)
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
from superset.utils import json
|
||||
from superset.utils.core import FilterOperator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MCP_DASHBOARD_TIME_FILTER_SUBJECT = "_mcp_dashboard_time_filter_subject"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatasetValidationResult:
|
||||
@@ -91,8 +97,6 @@ def validate_chart_dataset(
|
||||
Returns:
|
||||
DatasetValidationResult with validation status and any warnings
|
||||
"""
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.mcp_service.auth import has_dataset_access
|
||||
|
||||
@@ -108,9 +112,12 @@ def validate_chart_dataset(
|
||||
error="Chart has no dataset reference (datasource_id is None)",
|
||||
)
|
||||
|
||||
# Try to look up the dataset
|
||||
# Skip the DatasourceFilter base filter when not checking access, so the
|
||||
# lookup is a true existence check (it otherwise denies a guest outright).
|
||||
try:
|
||||
dataset = DatasetDAO.find_by_id(datasource_id)
|
||||
dataset = DatasetDAO.find_by_id(
|
||||
datasource_id, skip_base_filter=not check_access
|
||||
)
|
||||
|
||||
if dataset is None:
|
||||
return DatasetValidationResult(
|
||||
@@ -186,8 +193,6 @@ def generate_explore_link(
|
||||
this skips the permalink path and returns an ``/explore/?form_data_key=...``
|
||||
URL directly.
|
||||
"""
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.commands.explore.form_data.parameters import CommandParameters
|
||||
from superset.commands.explore.permalink.create import CreateExplorePermalinkCommand
|
||||
@@ -295,51 +300,6 @@ def _find_dataset_by_id_or_uuid(dataset_id: int | str | None) -> "SqlaTable | No
|
||||
return DatasetDAO.find_by_id_or_uuid(str(dataset_id))
|
||||
|
||||
|
||||
def _is_dataset_column_temporal(
|
||||
col: Any, column_name: str, db_engine_spec: Any
|
||||
) -> bool:
|
||||
"""Decide temporality for a single dataset column, mirroring
|
||||
TableColumn.is_temporal: native temporal SQL types are always
|
||||
temporal, and is_dttm=True is otherwise trusted over the raw SQL
|
||||
type -- this is the standard, supported way to mark a non-temporal
|
||||
column (e.g. a VARCHAR "ds" partition column on Hive/Presto/Trino)
|
||||
as a date.
|
||||
|
||||
The one case guarded against is a plain NUMERIC column (e.g. an
|
||||
integer "year"/"month" column) that Superset's column-name
|
||||
heuristics may have mis-flagged as is_dttm=True with no
|
||||
python_date_format to parse it -- applying DATE_TRUNC/time_grain to
|
||||
that would fail at query time.
|
||||
"""
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
is_dttm = bool(getattr(col, "is_dttm", False))
|
||||
col_type = col.type
|
||||
if not col_type:
|
||||
return is_dttm # No type info, trust is_dttm flag
|
||||
|
||||
column_spec = db_engine_spec.get_column_spec(col_type)
|
||||
generic_type = column_spec.generic_type if column_spec else None
|
||||
|
||||
if generic_type == GenericDataType.TEMPORAL:
|
||||
return True
|
||||
if not is_dttm:
|
||||
return False
|
||||
if generic_type != GenericDataType.NUMERIC or getattr(
|
||||
col, "python_date_format", None
|
||||
):
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Column '%s' is marked is_dttm=True but has numeric type '%s' with "
|
||||
"no python_date_format; treating as non-temporal to avoid an "
|
||||
"invalid DATE_TRUNC on a numeric column",
|
||||
column_name,
|
||||
col_type,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def is_column_truly_temporal(
|
||||
column_name: str,
|
||||
dataset_id: int | str | None,
|
||||
@@ -347,7 +307,7 @@ def is_column_truly_temporal(
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a column is truly temporal, mirroring TableColumn.is_temporal
|
||||
(see ``_is_dataset_column_temporal`` for the precedence rules).
|
||||
using the shared dataset temporal predicate.
|
||||
|
||||
Args:
|
||||
column_name: Name of the column to check
|
||||
@@ -373,7 +333,7 @@ def is_column_truly_temporal(
|
||||
for col in dataset.columns:
|
||||
if col.column_name.lower() == column_lower:
|
||||
db_engine_spec = dataset.database.db_engine_spec
|
||||
return _is_dataset_column_temporal(col, column_name, db_engine_spec)
|
||||
return is_dataset_column_temporal(col, column_name, db_engine_spec)
|
||||
|
||||
return True # Default if column not found
|
||||
|
||||
@@ -388,13 +348,7 @@ def is_column_truly_temporal(
|
||||
|
||||
|
||||
def map_config_to_form_data(
|
||||
config: TableChartConfig
|
||||
| XYChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig
|
||||
| BigNumberChartConfig,
|
||||
config: ChartConfig,
|
||||
dataset_id: int | str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Map chart config to Superset form_data via the plugin registry.
|
||||
@@ -434,6 +388,7 @@ def map_config_to_form_data(
|
||||
parts.append("Suggestions: " + "; ".join(error.suggestions))
|
||||
raise ValueError(" ".join(parts))
|
||||
|
||||
_bind_dashboard_time_range_filter(form_data, config, dataset_id)
|
||||
return form_data
|
||||
|
||||
|
||||
@@ -802,6 +757,98 @@ def _ensure_temporal_adhoc_filter(form_data: Dict[str, Any], column: str) -> Non
|
||||
form_data["adhoc_filters"] = existing
|
||||
|
||||
|
||||
def _has_generated_temporal_filter(form_data: Dict[str, Any], column: str) -> bool:
|
||||
"""Return whether form data contains the neutral generated time binding."""
|
||||
return any(
|
||||
isinstance(filter_, dict)
|
||||
and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
|
||||
and filter_.get("subject") == column
|
||||
and filter_.get("comparator") == NO_TIME_RANGE
|
||||
for filter_ in form_data.get("adhoc_filters", [])
|
||||
)
|
||||
|
||||
|
||||
def _ensure_generated_temporal_binding(form_data: Dict[str, Any], column: str) -> None:
|
||||
"""Add a neutral time filter and record its generated provenance."""
|
||||
_ensure_temporal_adhoc_filter(form_data, column)
|
||||
if _has_generated_temporal_filter(form_data, column):
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = column
|
||||
|
||||
|
||||
def _bind_dashboard_time_range_filter(
|
||||
form_data: Dict[str, Any],
|
||||
config: ChartConfig,
|
||||
dataset_id: int | str | None,
|
||||
) -> None:
|
||||
"""Bind charts without time configuration to a temporal filter subject."""
|
||||
if temporal_column := getattr(config, "temporal_column", None):
|
||||
if _is_temporal_for_dashboard_binding(temporal_column, dataset_id):
|
||||
granularity = form_data.get("granularity_sqla")
|
||||
if isinstance(granularity, str) and granularity != temporal_column:
|
||||
# QueryContextFactory gives granularity precedence over a temporal
|
||||
# filter, so a different granularity would bind both columns.
|
||||
form_data["granularity_sqla"] = None
|
||||
_ensure_temporal_adhoc_filter(form_data, temporal_column)
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = temporal_column
|
||||
return
|
||||
|
||||
dataset = None
|
||||
if dataset_id:
|
||||
try:
|
||||
dataset = _find_dataset_by_id_or_uuid(dataset_id)
|
||||
except (AttributeError, RuntimeError, ValueError, SQLAlchemyError) as ex:
|
||||
logger.debug(
|
||||
"Could not resolve dataset %s for dashboard time binding: %s",
|
||||
dataset_id,
|
||||
ex,
|
||||
)
|
||||
return
|
||||
|
||||
granularity = form_data.get("granularity_sqla")
|
||||
if isinstance(granularity, str) and _is_temporal_for_dashboard_binding(
|
||||
granularity, dataset_id, dataset
|
||||
):
|
||||
# Temporal XY mappers create the neutral filter before this binding pass.
|
||||
# Record its provenance so preview updates can replace it if the subject
|
||||
# changes, without treating user-authored temporal ranges as generated.
|
||||
if _has_generated_temporal_filter(form_data, granularity):
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = granularity
|
||||
return
|
||||
|
||||
x_axis = form_data.get("x_axis")
|
||||
if isinstance(x_axis, str) and _is_temporal_for_dashboard_binding(
|
||||
x_axis, dataset_id, dataset
|
||||
):
|
||||
_ensure_temporal_adhoc_filter(form_data, x_axis)
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = x_axis
|
||||
return
|
||||
|
||||
main_dttm_col = getattr(dataset, "main_dttm_col", None)
|
||||
if isinstance(main_dttm_col, str) and _is_temporal_for_dashboard_binding(
|
||||
main_dttm_col, dataset_id, dataset
|
||||
):
|
||||
_ensure_temporal_adhoc_filter(form_data, main_dttm_col)
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = main_dttm_col
|
||||
|
||||
|
||||
def _is_temporal_for_dashboard_binding(
|
||||
column: str,
|
||||
dataset_id: int | str | None,
|
||||
dataset: "SqlaTable | None" = None,
|
||||
) -> bool:
|
||||
"""Check temporal metadata without making chart mapping fail on lookup errors."""
|
||||
try:
|
||||
return is_column_truly_temporal(column, dataset_id, dataset=dataset)
|
||||
except (AttributeError, RuntimeError, ValueError, SQLAlchemyError) as ex:
|
||||
logger.debug(
|
||||
"Could not validate temporal column %s for dataset %s: %s",
|
||||
column,
|
||||
dataset_id,
|
||||
ex,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_default_x_axis(
|
||||
config: XYChartConfig, dataset_id: int | str | None
|
||||
) -> tuple[XYChartConfig, "SqlaTable | None"]:
|
||||
@@ -908,7 +955,10 @@ def map_xy_config( # noqa: C901
|
||||
|
||||
_add_adhoc_filters(form_data, config.filters)
|
||||
|
||||
if x_is_temporal:
|
||||
# A shared explicit temporal_column is the dashboard binding source of truth.
|
||||
# Defer to _bind_dashboard_time_range_filter instead of also binding the
|
||||
# temporal x-axis, which would apply the dashboard range to both columns.
|
||||
if x_is_temporal and not config.temporal_column:
|
||||
_ensure_temporal_adhoc_filter(form_data, config.x.name)
|
||||
|
||||
_add_xy_limits(form_data, config)
|
||||
@@ -1104,7 +1154,7 @@ def map_big_number_config(
|
||||
# panel, which exposes an `adhoc_filters` control even though there's no
|
||||
# dedicated time-column control for the total variant.
|
||||
if temporal_column := _resolve_big_number_temporal_column(config, dataset_id):
|
||||
_ensure_temporal_adhoc_filter(form_data, temporal_column)
|
||||
_ensure_generated_temporal_binding(form_data, temporal_column)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
@@ -248,6 +248,8 @@ def get_chart_configs_resource() -> str:
|
||||
],
|
||||
"general": [
|
||||
"Always verify column names with get_dataset_info before charting",
|
||||
"Set temporal_column when dashboard time filters should use a column "
|
||||
"other than the dataset's main temporal column",
|
||||
"Use generate_explore_link for preview, generate_chart for saving",
|
||||
"Each column label must be unique across the entire configuration",
|
||||
"Column names must match: ^[a-zA-Z0-9_][a-zA-Z0-9_ \\-\\.]*$",
|
||||
|
||||
@@ -772,6 +772,33 @@ class UnknownFieldCheckMixin(BaseModel):
|
||||
return _check_unknown_fields(data, cls)
|
||||
|
||||
|
||||
class BaseChartConfig(UnknownFieldCheckMixin):
|
||||
"""Fields shared by every MCP chart configuration."""
|
||||
|
||||
temporal_column: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Temporal column used to bind dashboard time-range filters. "
|
||||
"When omitted, charts without a temporal axis use the dataset's "
|
||||
"main temporal column."
|
||||
),
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
@field_validator("temporal_column")
|
||||
@classmethod
|
||||
def sanitize_temporal_column(cls, v: str | None) -> str | None:
|
||||
"""Sanitize temporal column names to prevent SQL injection."""
|
||||
return sanitize_user_input(
|
||||
v,
|
||||
"Temporal column",
|
||||
max_length=255,
|
||||
check_sql_keywords=True,
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
|
||||
class ColumnRef(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
@@ -1024,7 +1051,7 @@ class SortByConfig(UnknownFieldCheckMixin):
|
||||
|
||||
|
||||
# Actual chart types
|
||||
class PieChartConfig(UnknownFieldCheckMixin):
|
||||
class PieChartConfig(BaseChartConfig):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["pie"] = "pie"
|
||||
@@ -1098,7 +1125,7 @@ class PieChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class PivotTableChartConfig(UnknownFieldCheckMixin):
|
||||
class PivotTableChartConfig(BaseChartConfig):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["pivot_table"] = "pivot_table"
|
||||
@@ -1162,7 +1189,7 @@ class PivotTableChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
|
||||
class MixedTimeseriesChartConfig(BaseChartConfig):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["mixed_timeseries"] = "mixed_timeseries"
|
||||
@@ -1255,7 +1282,7 @@ class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class HandlebarsChartConfig(UnknownFieldCheckMixin):
|
||||
class HandlebarsChartConfig(BaseChartConfig):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
chart_type: Literal["handlebars"] = Field(
|
||||
@@ -1371,7 +1398,7 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
class BigNumberChartConfig(BaseChartConfig):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
chart_type: Literal["big_number"] = Field(
|
||||
@@ -1391,17 +1418,6 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
"Must include an aggregate function (e.g., SUM, COUNT)."
|
||||
),
|
||||
)
|
||||
temporal_column: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Temporal column for the trendline x-axis. Required when "
|
||||
"show_trendline is True. Also used (whether or not a trendline is "
|
||||
"shown) to bind the chart's dashboard time-range filter; when "
|
||||
"omitted, the dataset's default temporal column is used instead."
|
||||
),
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
time_grain: TimeGrain | None = Field(
|
||||
None,
|
||||
description=(
|
||||
@@ -1495,18 +1511,6 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
description="Filters to apply",
|
||||
)
|
||||
|
||||
@field_validator("temporal_column")
|
||||
@classmethod
|
||||
def sanitize_temporal_column(cls, v: str | None) -> str | None:
|
||||
"""Sanitize temporal column name to prevent SQL injection."""
|
||||
return sanitize_user_input(
|
||||
v,
|
||||
"Temporal column",
|
||||
max_length=255,
|
||||
check_sql_keywords=True,
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_trendline_fields(self) -> Self:
|
||||
"""Validate trendline requires temporal column."""
|
||||
@@ -1576,7 +1580,7 @@ class TableColumnConfig(UnknownFieldCheckMixin):
|
||||
)
|
||||
|
||||
|
||||
class TableChartConfig(UnknownFieldCheckMixin):
|
||||
class TableChartConfig(BaseChartConfig):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["table"] = "table"
|
||||
@@ -1707,7 +1711,7 @@ def _metric_display_label(col: ColumnRef) -> str:
|
||||
return col.label or col.name or ""
|
||||
|
||||
|
||||
class XYChartConfig(UnknownFieldCheckMixin):
|
||||
class XYChartConfig(BaseChartConfig):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["xy"] = "xy"
|
||||
@@ -1878,7 +1882,7 @@ class XYChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class HistogramChartConfig(UnknownFieldCheckMixin):
|
||||
class HistogramChartConfig(BaseChartConfig):
|
||||
"""Config for histogram charts (viz_type ``histogram_v2``)."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -1921,7 +1925,7 @@ class HistogramChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class BoxPlotChartConfig(UnknownFieldCheckMixin):
|
||||
class BoxPlotChartConfig(BaseChartConfig):
|
||||
"""Config for box plot charts (viz_type ``box_plot``)."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -2036,7 +2040,7 @@ class BoxPlotChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
class WaterfallChartConfig(UnknownFieldCheckMixin):
|
||||
class WaterfallChartConfig(BaseChartConfig):
|
||||
"""Config for waterfall charts (viz_type ``waterfall``)."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -2574,6 +2578,16 @@ class DataColumn(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChartQueryResult(BaseModel):
|
||||
"""Data returned by one query in a chart's query context."""
|
||||
|
||||
query_index: int = Field(description="Zero-based query position")
|
||||
columns: list[str] = Field(description="Column names returned by the query")
|
||||
data: list[dict[str, Any]] = Field(description="Actual data rows")
|
||||
row_count: int = Field(description="Rows returned")
|
||||
total_rows: int | None = Field(None, description="Total available rows")
|
||||
|
||||
|
||||
class ChartData(BaseModel):
|
||||
"""Rich chart data response with statistical insights."""
|
||||
|
||||
@@ -2585,6 +2599,13 @@ class ChartData(BaseModel):
|
||||
# Enhanced data description
|
||||
columns: List[DataColumn] = Field(description="Rich column metadata")
|
||||
data: List[Dict[str, Any]] = Field(description="Actual data rows")
|
||||
query_results: list[ChartQueryResult] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"All query results for multi-query charts. The top-level columns and data "
|
||||
"fields remain aliases for the first query for backward compatibility."
|
||||
),
|
||||
)
|
||||
|
||||
# Data insights
|
||||
row_count: int = Field(description="Rows returned")
|
||||
|
||||
@@ -326,7 +326,7 @@ async def generate_chart( # noqa: C901
|
||||
|
||||
# Persist the chart only when explicitly requested (save_chart=False by default)
|
||||
if request.save_chart:
|
||||
await ctx.report_progress(2, 5, "Creating chart in database")
|
||||
await ctx.report_progress(2, 5, "Validating chart query")
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
|
||||
# Find the dataset to get its numeric ID
|
||||
@@ -414,6 +414,64 @@ async def generate_chart( # noqa: C901
|
||||
)
|
||||
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
|
||||
|
||||
# Compile before persisting. A failed query must not leave a broken
|
||||
# chart row behind and then rely on a later transaction to delete it.
|
||||
with event_logger.log_context(action="mcp.generate_chart.compile_check"):
|
||||
compile_result = _compile_chart(form_data, dataset.id)
|
||||
if not compile_result.success:
|
||||
logger.warning(
|
||||
"Compile check failed before chart creation: %s",
|
||||
compile_result.error,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Chart compile check failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message="Chart query failed to execute. The chart was not saved.",
|
||||
details=str(compile_result.error) or "",
|
||||
suggestions=[
|
||||
"Check that all columns exist in the dataset",
|
||||
"Verify aggregate functions are compatible with column types",
|
||||
"Ensure filters reference valid columns",
|
||||
"Try simplifying the chart configuration",
|
||||
],
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": error.model_dump(),
|
||||
"form_data": (
|
||||
_sanitize_generate_chart_form_data_for_llm_context(
|
||||
form_data
|
||||
)
|
||||
),
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
"optimization_suggestions": [],
|
||||
},
|
||||
"warnings": (
|
||||
sanitization_warnings
|
||||
+ runtime_warnings
|
||||
+ response_warnings
|
||||
+ compile_result.warnings
|
||||
),
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
await ctx.report_progress(3, 5, "Creating chart in database")
|
||||
|
||||
try:
|
||||
with event_logger.log_context(action="mcp.generate_chart.db_write"):
|
||||
command = CreateChartCommand(
|
||||
@@ -489,66 +547,6 @@ async def generate_chart( # noqa: C901
|
||||
# Add any validation warnings (e.g., virtual dataset warnings)
|
||||
response_warnings.extend(dataset_check.warnings)
|
||||
|
||||
# Compile check: execute the chart query to catch runtime errors
|
||||
await ctx.report_progress(3, 5, "Running compile check (test query)")
|
||||
with event_logger.log_context(
|
||||
action="mcp.generate_chart.compile_check"
|
||||
):
|
||||
compile_result = _compile_chart(form_data, dataset.id)
|
||||
if not compile_result.success:
|
||||
# Query failed — delete the broken chart and return an error
|
||||
logger.warning(
|
||||
"Compile check failed for chart %s: %s",
|
||||
chart_id,
|
||||
compile_result.error,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Chart compile check failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
ChartDAO.delete([chart])
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message=(
|
||||
"Chart query failed to execute. The chart was not saved."
|
||||
),
|
||||
details=str(compile_result.error) or "",
|
||||
suggestions=[
|
||||
"Check that all columns exist in the dataset",
|
||||
"Verify aggregate functions are compatible "
|
||||
"with column types",
|
||||
"Ensure filters reference valid columns",
|
||||
"Try simplifying the chart configuration",
|
||||
],
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": error.model_dump(),
|
||||
"form_data": (
|
||||
_sanitize_generate_chart_form_data_for_llm_context(
|
||||
form_data
|
||||
)
|
||||
),
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
"optimization_suggestions": [],
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
except CommandException as e:
|
||||
logger.error("Chart creation failed: %s", e)
|
||||
await ctx.error("Chart creation failed: error=%s" % (str(e),))
|
||||
|
||||
@@ -32,6 +32,7 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.slice import Slice
|
||||
|
||||
from superset.charts.data.form_data import set_query_context_form_data
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
|
||||
from superset.extensions import event_logger
|
||||
@@ -47,6 +48,7 @@ from superset.mcp_service.chart.chart_utils import validate_chart_dataset
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ChartData,
|
||||
ChartError,
|
||||
ChartQueryResult,
|
||||
DataColumn,
|
||||
GetChartDataRequest,
|
||||
PerformanceMetadata,
|
||||
@@ -112,12 +114,14 @@ _MAX_RECOMMENDATIONS = 4
|
||||
|
||||
def _coerce_row_limit(value: Any, default: int) -> int:
|
||||
"""Coerce a row_limit (which may arrive as a str from chart.params) to int,
|
||||
falling back to ``default`` when it is missing or non-numeric — downstream
|
||||
apply_max_row_limit compares it against an int."""
|
||||
falling back to ``default`` when it is missing, non-numeric, or non-positive.
|
||||
A non-positive limit would otherwise flow through apply_max_row_limit as-is
|
||||
and emit ``LIMIT -1`` (unbounded on some engines, an error on others)."""
|
||||
try:
|
||||
return int(value)
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return coerced if coerced > 0 else default
|
||||
|
||||
|
||||
def _recommend_visualizations(
|
||||
@@ -264,6 +268,12 @@ def _sanitize_chart_data_for_llm_context(chart_data: ChartData) -> ChartData:
|
||||
field_path=("data",),
|
||||
excluded_field_names=frozenset(),
|
||||
)
|
||||
for query_index, query_result in enumerate(payload.get("query_results") or []):
|
||||
query_result["data"] = sanitize_for_llm_context(
|
||||
query_result.get("data", []),
|
||||
field_path=("query_results", str(query_index), "data"),
|
||||
excluded_field_names=frozenset(),
|
||||
)
|
||||
payload["columns"] = [
|
||||
{
|
||||
**column,
|
||||
@@ -279,6 +289,29 @@ def _sanitize_chart_data_for_llm_context(chart_data: ChartData) -> ChartData:
|
||||
return ChartData.model_validate(payload)
|
||||
|
||||
|
||||
def _build_query_results(
|
||||
query_results: list[dict[str, Any]], limit: int | None
|
||||
) -> list[ChartQueryResult] | None:
|
||||
"""Preserve every result when a chart executes more than one query."""
|
||||
if len(query_results) <= 1:
|
||||
return None
|
||||
|
||||
results = []
|
||||
for index, query_result in enumerate(query_results):
|
||||
data = query_result.get("data", [])
|
||||
returned_data = data[:limit] if limit else data
|
||||
results.append(
|
||||
ChartQueryResult(
|
||||
query_index=index,
|
||||
columns=query_result.get("colnames", []),
|
||||
data=returned_data,
|
||||
row_count=len(returned_data),
|
||||
total_rows=query_result.get("rowcount"),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["data"],
|
||||
class_permission_name="Chart",
|
||||
@@ -335,6 +368,13 @@ async def get_chart_data( # noqa: C901
|
||||
|
||||
# Handle unsaved chart (form_data_key only, no identifier)
|
||||
if not request.identifier and request.form_data_key:
|
||||
# The unsaved-chart cache is not dashboard-scoped, so guests can't
|
||||
# use it.
|
||||
if guest_scope.is_guest_read():
|
||||
return ChartError(
|
||||
error="No accessible chart found for this request.",
|
||||
error_type="NotFound",
|
||||
)
|
||||
with event_logger.log_context(
|
||||
action="mcp.get_chart_data.unsaved_chart_from_cache"
|
||||
):
|
||||
@@ -433,30 +473,30 @@ async def get_chart_data( # noqa: C901
|
||||
)
|
||||
logger.info("Getting data for chart %s: %s", chart.id, chart.slice_name)
|
||||
|
||||
# Skip the dataset RBAC pre-check for guests (see guest_scope.is_guest_read).
|
||||
if not guest_scope.is_guest_read():
|
||||
validation_result = validate_chart_dataset(
|
||||
chart.datasource_id, check_access=True
|
||||
# Guests skip the RBAC check (authorize_query covers it) but keep the
|
||||
# existence check, so a deleted dataset still returns
|
||||
# DatasetNotAccessible.
|
||||
validation_result = validate_chart_dataset(
|
||||
chart.datasource_id, check_access=not guest_scope.is_guest_read()
|
||||
)
|
||||
if not validation_result.is_valid:
|
||||
await ctx.warning(
|
||||
"Chart found but dataset is not accessible: %s"
|
||||
% (validation_result.error,)
|
||||
)
|
||||
if not validation_result.is_valid:
|
||||
await ctx.warning(
|
||||
"Chart found but dataset is not accessible: %s"
|
||||
% (validation_result.error,)
|
||||
)
|
||||
logger.warning(
|
||||
"get_chart_data: dataset not accessible for chart_id=%s: %s",
|
||||
chart.id,
|
||||
validation_result.error,
|
||||
)
|
||||
return ChartError(
|
||||
error=validation_result.error
|
||||
or "Chart's dataset is not accessible. "
|
||||
"Dataset may have been deleted.",
|
||||
error_type="DatasetNotAccessible",
|
||||
)
|
||||
# Log any warnings (e.g., virtual dataset warnings)
|
||||
for warning in validation_result.warnings:
|
||||
await ctx.warning("Dataset warning: %s" % (warning,))
|
||||
logger.warning(
|
||||
"get_chart_data: dataset not accessible for chart_id=%s: %s",
|
||||
chart.id,
|
||||
validation_result.error,
|
||||
)
|
||||
return ChartError(
|
||||
error=validation_result.error
|
||||
or "Chart's dataset is not accessible. Dataset may have been deleted.",
|
||||
error_type="DatasetNotAccessible",
|
||||
)
|
||||
# Log any warnings (e.g., virtual dataset warnings)
|
||||
for warning in validation_result.warnings:
|
||||
await ctx.warning("Dataset warning: %s" % (warning,))
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
@@ -469,8 +509,10 @@ async def get_chart_data( # noqa: C901
|
||||
from superset.charts.schemas import ChartDataQueryContextSchema
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
|
||||
# Check if form_data_key is provided - use cached form_data instead
|
||||
if request.form_data_key:
|
||||
# Guests always read the saved chart config: the cache isn't
|
||||
# dashboard-scoped and its payload could point the query at another
|
||||
# datasource.
|
||||
if request.form_data_key and not guest_scope.is_guest_read():
|
||||
with event_logger.log_context(
|
||||
action="mcp.get_chart_data.unsaved_state_override"
|
||||
):
|
||||
@@ -513,11 +555,13 @@ async def get_chart_data( # noqa: C901
|
||||
|
||||
# If using cached form_data, we need to build query_context from it
|
||||
if using_unsaved_state and cached_form_data_dict is not None:
|
||||
# Build query context from cached form_data (unsaved state)
|
||||
row_limit = (
|
||||
# row_limit may arrive as a str. The trailing fallback keeps a
|
||||
# falsy 0 resolving to ROW_LIMIT.
|
||||
row_limit = _coerce_row_limit(
|
||||
request.limit
|
||||
or cached_form_data_dict.get("row_limit")
|
||||
or current_app.config["ROW_LIMIT"]
|
||||
or current_app.config["ROW_LIMIT"],
|
||||
current_app.config["ROW_LIMIT"],
|
||||
)
|
||||
|
||||
query_context = build_query_context_from_form_data(
|
||||
@@ -628,8 +672,8 @@ async def get_chart_data( # noqa: C901
|
||||
# Apply request overrides to the saved query_context
|
||||
query_context_json["force"] = request.force_refresh
|
||||
|
||||
# Apply row limit if specified (respects chart's configured limits)
|
||||
if request.limit:
|
||||
# Ignore a non-positive limit so it can't emit LIMIT -1 downstream.
|
||||
if request.limit and request.limit > 0:
|
||||
for query in query_context_json.get("queries", []):
|
||||
query["row_limit"] = request.limit
|
||||
|
||||
@@ -663,6 +707,12 @@ async def get_chart_data( # noqa: C901
|
||||
if guest_dashboard_id is not None:
|
||||
guest_scope.authorize_query(query_context, guest_dashboard_id, chart)
|
||||
|
||||
set_query_context_form_data(
|
||||
query_context,
|
||||
chart.datasource_id,
|
||||
chart.datasource_type,
|
||||
)
|
||||
|
||||
# Execute the query
|
||||
with event_logger.log_context(action="mcp.get_chart_data.query_execution"):
|
||||
command = ChartDataCommand(query_context)
|
||||
@@ -703,7 +753,7 @@ async def get_chart_data( # noqa: C901
|
||||
)
|
||||
|
||||
# Check if we have data to work with
|
||||
if not data:
|
||||
if not any(query.get("data") for query in result["queries"]):
|
||||
await ctx.warning("No data in query results: chart_id=%s" % (chart.id,))
|
||||
logger.warning(
|
||||
"get_chart_data: no data in query results for chart_id=%s",
|
||||
@@ -883,6 +933,9 @@ async def get_chart_data( # noqa: C901
|
||||
chart_type=chart.viz_type or "unknown",
|
||||
columns=columns,
|
||||
data=data[: request.limit] if request.limit else data,
|
||||
query_results=_build_query_results(
|
||||
result["queries"], request.limit
|
||||
),
|
||||
row_count=len(data),
|
||||
total_rows=query_result.get("rowcount"),
|
||||
summary=summary,
|
||||
@@ -994,8 +1047,11 @@ async def _query_from_form_data(
|
||||
error_type="InvalidFormData",
|
||||
)
|
||||
|
||||
row_limit = (
|
||||
request.limit or form_data.get("row_limit") or current_app.config["ROW_LIMIT"]
|
||||
# row_limit may arrive as a str. The trailing fallback keeps a falsy 0
|
||||
# resolving to ROW_LIMIT.
|
||||
row_limit = _coerce_row_limit(
|
||||
request.limit or form_data.get("row_limit") or current_app.config["ROW_LIMIT"],
|
||||
current_app.config["ROW_LIMIT"],
|
||||
)
|
||||
viz_type = form_data.get("viz_type", "unknown")
|
||||
|
||||
@@ -1029,7 +1085,7 @@ async def _query_from_form_data(
|
||||
data = query_result.get("data", [])
|
||||
raw_columns = query_result.get("colnames", [])
|
||||
|
||||
if not data:
|
||||
if not any(query.get("data") for query in result["queries"]):
|
||||
logger.warning(
|
||||
"get_chart_data: no data for unsaved chart (form_data_key=%s)",
|
||||
request.form_data_key,
|
||||
@@ -1078,6 +1134,7 @@ async def _query_from_form_data(
|
||||
chart_type=viz_type,
|
||||
columns=columns,
|
||||
data=data[: request.limit] if request.limit else data,
|
||||
query_results=_build_query_results(result["queries"], request.limit),
|
||||
row_count=len(data),
|
||||
total_rows=query_result.get("rowcount"),
|
||||
summary=summary,
|
||||
|
||||
@@ -29,6 +29,7 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.mcp_service import guest_scope
|
||||
from superset.mcp_service.chart.ascii_charts import (
|
||||
generate_ascii_chart,
|
||||
generate_ascii_table,
|
||||
@@ -276,8 +277,6 @@ class PreviewFormatStrategy:
|
||||
def _authorize_guest_query(self, query_context: Any) -> None:
|
||||
"""For a guest, attach the dashboard context so raise_for_access
|
||||
authorizes the preview query."""
|
||||
from superset.mcp_service import guest_scope
|
||||
|
||||
if (dashboard_id := guest_scope.guest_dashboard_id(self.chart)) is not None:
|
||||
guest_scope.authorize_query(query_context, dashboard_id, self.chart)
|
||||
|
||||
@@ -1293,13 +1292,11 @@ async def _get_chart_preview_internal( # noqa: C901
|
||||
logger.info("Generating preview for chart %s", getattr(chart, "id", "NO_ID"))
|
||||
logger.info("Chart datasource_id: %s", getattr(chart, "datasource_id", "NONE"))
|
||||
|
||||
# Skip the dataset pre-check for transient charts (no ID) and for guests
|
||||
# (authorized via the dashboard context, not dataset RBAC).
|
||||
from superset.mcp_service import guest_scope
|
||||
|
||||
if getattr(chart, "id", None) is not None and not guest_scope.is_guest_read():
|
||||
# Transient charts have a falsy id of 0, so skip the pre-check for them.
|
||||
# Guests keep the existence check but skip RBAC (dashboard-authorized).
|
||||
if getattr(chart, "id", None):
|
||||
validation_result = validate_chart_dataset(
|
||||
chart.datasource_id, check_access=True
|
||||
chart.datasource_id, check_access=not guest_scope.is_guest_read()
|
||||
)
|
||||
if not validation_result.is_valid:
|
||||
await ctx.warning(
|
||||
|
||||
@@ -102,7 +102,8 @@ async def list_charts(
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``slice_name``, ``viz_type``, ``description``,
|
||||
``changed_on``, ``created_on``
|
||||
``changed_on``, ``changed_on_delta_humanized`` (alias for ``changed_on``),
|
||||
``created_on``
|
||||
|
||||
To filter by a person, call find_users to resolve the name to a user ID,
|
||||
then pass it as a filter: filters=[{"col": "created_by_fk", "opr": "eq",
|
||||
|
||||
@@ -38,7 +38,9 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
generate_chart_name,
|
||||
generate_explore_link,
|
||||
map_config_to_form_data,
|
||||
MCP_DASHBOARD_TIME_FILTER_SUBJECT,
|
||||
merge_table_column_config,
|
||||
NO_TIME_RANGE,
|
||||
)
|
||||
from superset.mcp_service.chart.compile import validate_and_compile
|
||||
from superset.mcp_service.chart.preview_utils import (
|
||||
@@ -103,6 +105,50 @@ def _get_previous_form_data(form_data_key: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _preserve_previous_adhoc_filters(
|
||||
new_form_data: dict[str, Any], previous_form_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Preserve cached filters without dropping mapper-generated bindings."""
|
||||
previous_filters = previous_form_data.get("adhoc_filters")
|
||||
if not isinstance(previous_filters, list) or not previous_filters:
|
||||
return
|
||||
|
||||
generated_filters = new_form_data.get("adhoc_filters", [])
|
||||
previous_binding = previous_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
|
||||
new_binding = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
|
||||
merged_filters = [
|
||||
filter_
|
||||
for filter_ in previous_filters
|
||||
if not (
|
||||
previous_binding
|
||||
and previous_binding != new_binding
|
||||
and isinstance(filter_, dict)
|
||||
and filter_.get("operator") == "TEMPORAL_RANGE"
|
||||
and filter_.get("subject") == previous_binding
|
||||
and filter_.get("comparator") == NO_TIME_RANGE
|
||||
)
|
||||
]
|
||||
for generated_filter in generated_filters:
|
||||
if not isinstance(generated_filter, dict):
|
||||
if generated_filter not in merged_filters:
|
||||
merged_filters.append(generated_filter)
|
||||
continue
|
||||
|
||||
is_same_filter = any(
|
||||
isinstance(previous_filter, dict)
|
||||
and previous_filter.get("clause") == generated_filter.get("clause")
|
||||
and previous_filter.get("expressionType")
|
||||
== generated_filter.get("expressionType")
|
||||
and previous_filter.get("subject") == generated_filter.get("subject")
|
||||
and previous_filter.get("operator") == generated_filter.get("operator")
|
||||
for previous_filter in merged_filters
|
||||
)
|
||||
if not is_same_filter:
|
||||
merged_filters.append(generated_filter)
|
||||
|
||||
new_form_data["adhoc_filters"] = merged_filters
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Chart",
|
||||
@@ -184,9 +230,10 @@ def update_chart_preview( # noqa: C901
|
||||
# Preserve adhoc filters from the previous cached form_data
|
||||
# when the new config doesn't explicitly specify filters
|
||||
if getattr(config, "filters", None) is None and previous_form_data:
|
||||
old_adhoc_filters = previous_form_data.get("adhoc_filters")
|
||||
if old_adhoc_filters:
|
||||
new_form_data["adhoc_filters"] = old_adhoc_filters
|
||||
_preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
previous_form_data,
|
||||
)
|
||||
if previous_form_data:
|
||||
merge_table_column_config(previous_form_data, new_form_data)
|
||||
|
||||
|
||||
@@ -39,6 +39,37 @@ _C = TypeVar("_C", bound=ChartConfig)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_dataset_column_temporal(
|
||||
column: Any, column_name: str, db_engine_spec: Any
|
||||
) -> bool:
|
||||
"""Return whether a dataset column is safe for temporal operations."""
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
is_dttm = bool(getattr(column, "is_dttm", False))
|
||||
column_type = column.type
|
||||
if not column_type:
|
||||
return is_dttm
|
||||
|
||||
column_spec = db_engine_spec.get_column_spec(column_type)
|
||||
generic_type = column_spec.generic_type if column_spec else None
|
||||
if generic_type == GenericDataType.TEMPORAL:
|
||||
return True
|
||||
if not is_dttm:
|
||||
return False
|
||||
if generic_type != GenericDataType.NUMERIC or getattr(
|
||||
column, "python_date_format", None
|
||||
):
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Column '%s' is marked is_dttm=True but has numeric type '%s' with "
|
||||
"no python_date_format; treating it as non-temporal",
|
||||
column_name,
|
||||
column_type,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
|
||||
"""Construct a :class:`DatasetContext` from an already-fetched ORM dataset.
|
||||
|
||||
@@ -48,13 +79,19 @@ def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
|
||||
if dataset is None:
|
||||
return None
|
||||
|
||||
database = getattr(dataset, "database", None)
|
||||
db_engine_spec = getattr(database, "db_engine_spec", None)
|
||||
columns: List[Dict[str, Any]] = []
|
||||
for col in getattr(dataset, "columns", []) or []:
|
||||
columns.append(
|
||||
{
|
||||
"name": col.column_name,
|
||||
"type": str(col.type) if col.type else "UNKNOWN",
|
||||
"is_temporal": getattr(col, "is_temporal", False),
|
||||
"is_temporal": (
|
||||
is_dataset_column_temporal(col, col.column_name, db_engine_spec)
|
||||
if db_engine_spec
|
||||
else getattr(col, "is_temporal", False)
|
||||
),
|
||||
"is_numeric": getattr(col, "is_numeric", False),
|
||||
}
|
||||
)
|
||||
@@ -69,7 +106,6 @@ def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
|
||||
}
|
||||
)
|
||||
|
||||
database = getattr(dataset, "database", None)
|
||||
database_name = getattr(database, "database_name", None) or ""
|
||||
return DatasetContext(
|
||||
id=dataset.id,
|
||||
@@ -123,6 +159,12 @@ class DatasetValidator:
|
||||
|
||||
return False, ChartErrorBuilder.dataset_not_found_error(dataset_id)
|
||||
|
||||
temporal_error = DatasetValidator._validate_temporal_column(
|
||||
config, dataset_context
|
||||
)
|
||||
if temporal_error:
|
||||
return False, temporal_error
|
||||
|
||||
# Collect all column references
|
||||
column_refs = DatasetValidator._extract_column_references(config)
|
||||
|
||||
@@ -153,6 +195,51 @@ class DatasetValidator:
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _validate_temporal_column(
|
||||
config: ChartConfig, dataset_context: DatasetContext
|
||||
) -> ChartGenerationError | None:
|
||||
"""Require an explicitly selected dashboard time column to be temporal."""
|
||||
temporal_column = getattr(config, "temporal_column", None)
|
||||
if not temporal_column:
|
||||
return None
|
||||
|
||||
matching_column = next(
|
||||
(
|
||||
column
|
||||
for column in dataset_context.available_columns
|
||||
if column["name"].lower() == temporal_column.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching_column is None:
|
||||
return ChartGenerationError(
|
||||
error_type="missing_temporal_column",
|
||||
message=f"Temporal column '{temporal_column}' does not exist",
|
||||
details="The temporal_column must reference a physical dataset column.",
|
||||
suggestions=[
|
||||
"Choose a temporal column from the dataset",
|
||||
"Remove temporal_column to use the dataset's default time column",
|
||||
],
|
||||
error_code="MISSING_TEMPORAL_COLUMN",
|
||||
)
|
||||
if matching_column.get("is_temporal", False):
|
||||
return None
|
||||
|
||||
return ChartGenerationError(
|
||||
error_type="invalid_temporal_column",
|
||||
message=f"Column '{temporal_column}' is not temporal",
|
||||
details=(
|
||||
"The temporal_column must reference a dataset column marked as "
|
||||
"temporal so dashboard time-range filters can bind to the chart."
|
||||
),
|
||||
suggestions=[
|
||||
"Choose a temporal column from the dataset",
|
||||
"Remove temporal_column to use the dataset's default time column",
|
||||
],
|
||||
error_code="NON_TEMPORAL_COLUMN",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_columns_exist( # noqa: C901
|
||||
column_refs: List[ColumnRef], dataset_context: DatasetContext
|
||||
@@ -290,7 +377,16 @@ class DatasetValidator:
|
||||
logger.warning("No plugin registered for chart_type=%r", chart_type)
|
||||
return []
|
||||
|
||||
return plugin.extract_column_refs(config)
|
||||
refs = plugin.extract_column_refs(config)
|
||||
temporal_column = getattr(config, "temporal_column", None)
|
||||
if temporal_column and not any(
|
||||
not ref.saved_metric
|
||||
and ref.name
|
||||
and ref.name.lower() == temporal_column.lower()
|
||||
for ref in refs
|
||||
):
|
||||
refs.append(ColumnRef(name=temporal_column))
|
||||
return refs
|
||||
|
||||
@staticmethod
|
||||
def _column_exists(column_name: str, dataset_context: DatasetContext) -> bool:
|
||||
@@ -424,7 +520,16 @@ class DatasetValidator:
|
||||
)
|
||||
return config
|
||||
|
||||
return plugin.normalize_column_refs(config, dataset_context)
|
||||
normalized_config = plugin.normalize_column_refs(config, dataset_context)
|
||||
if temporal_column := getattr(normalized_config, "temporal_column", None):
|
||||
canonical_temporal_column = DatasetValidator.get_canonical_column_name(
|
||||
temporal_column, dataset_context
|
||||
)
|
||||
if canonical_temporal_column != temporal_column:
|
||||
normalized_config = normalized_config.model_copy(
|
||||
update={"temporal_column": canonical_temporal_column}
|
||||
)
|
||||
return normalized_config
|
||||
|
||||
@staticmethod
|
||||
def _get_column_suggestions(
|
||||
|
||||
@@ -257,6 +257,7 @@ CHART_SORTABLE_COLUMNS = [
|
||||
"viz_type",
|
||||
"description",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
CHART_SEARCH_COLUMNS = ["slice_name", "description"]
|
||||
@@ -354,6 +355,7 @@ DATASET_SORTABLE_COLUMNS = [
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
DATASET_SEARCH_COLUMNS = ["table_name", "description"]
|
||||
@@ -450,6 +452,7 @@ DASHBOARD_SORTABLE_COLUMNS = [
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
DASHBOARD_SEARCH_COLUMNS = ["dashboard_title", "slug"]
|
||||
|
||||
@@ -56,15 +56,6 @@ DEFAULT_DASHBOARD_COLUMNS = [
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
SORTABLE_DASHBOARD_COLUMNS = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
|
||||
_DEFAULT_LIST_DASHBOARDS_REQUEST = ListDashboardsRequest()
|
||||
|
||||
|
||||
@@ -103,7 +94,8 @@ async def list_dashboards(
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``dashboard_title``, ``slug``, ``published``,
|
||||
``changed_on``, ``created_on``
|
||||
``changed_on``, ``changed_on_delta_humanized`` (alias for ``changed_on``),
|
||||
``created_on``
|
||||
|
||||
To filter by a person (e.g. "dashboards Maxime is working on"), do NOT pass
|
||||
the name as the search parameter — search matches titles and slugs only.
|
||||
|
||||
@@ -66,14 +66,6 @@ DEFAULT_DATASET_COLUMNS = [
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
SORTABLE_DATASET_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
|
||||
_DEFAULT_LIST_DATASETS_REQUEST = ListDatasetsRequest()
|
||||
|
||||
|
||||
@@ -115,7 +107,8 @@ async def list_datasets(
|
||||
``created_by_fk``, ``changed_by_fk``
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``table_name``, ``schema``, ``changed_on``, ``created_on``
|
||||
``id``, ``table_name``, ``schema``, ``changed_on``,
|
||||
``changed_on_delta_humanized`` (alias for ``changed_on``), ``created_on``
|
||||
|
||||
To filter by a person, call find_users to resolve the name to a user ID,
|
||||
then pass it as a filter: filters=[{"col": "created_by_fk", "opr": "eq",
|
||||
|
||||
@@ -31,6 +31,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.charts.data.form_data import set_query_context_form_data
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
|
||||
from superset.extensions import event_logger
|
||||
@@ -310,6 +311,8 @@ async def query_dataset( # noqa: C901
|
||||
custom_cache_timeout=request.cache_timeout,
|
||||
)
|
||||
|
||||
set_query_context_form_data(query_context, dataset.id, "table")
|
||||
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
@@ -790,6 +790,7 @@ def get_mcp_config(app_config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"MCP_CHART_PLUGIN_ENABLED_FUNC": MCP_CHART_PLUGIN_ENABLED_FUNC,
|
||||
"MCP_EMBEDDED_GUEST_AUTH_ENABLED": MCP_EMBEDDED_GUEST_AUTH_ENABLED,
|
||||
"MCP_GUEST_ALLOWED_TOOLS": set(MCP_GUEST_ALLOWED_TOOLS),
|
||||
"MCP_RESTRICTED_TOOL_POLICY": MCP_RESTRICTED_TOOL_POLICY,
|
||||
**MCP_SESSION_CONFIG,
|
||||
**MCP_CSRF_CONFIG,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,20 @@ F = TypeVar("F", bound=BaseModel) # For filter types
|
||||
L = TypeVar("L", bound=BaseModel) # For list response schemas
|
||||
|
||||
|
||||
# Humanized/computed columns accepted as order_column aliases, mapped to the
|
||||
# real sortable column that backs them. Superset's own REST APIs (see the
|
||||
# `@renders("changed_on")` binding on `changed_on_delta_humanized` in
|
||||
# models/helpers.py, and each ModelRestApi's `order_columns`) already sort
|
||||
# by the underlying timestamp when asked to order by the humanized string,
|
||||
# since the humanized value is derived from it and isn't itself a queryable
|
||||
# column. Mirrored here so DAO.list() (which does a plain
|
||||
# `getattr(model, order_column)`) receives an actual column, not a Python
|
||||
# property/method.
|
||||
_ORDER_COLUMN_ALIASES: dict[str, str] = {
|
||||
"changed_on_delta_humanized": "changed_on",
|
||||
}
|
||||
|
||||
|
||||
class BaseCore(ABC):
|
||||
"""
|
||||
Abstract base class for all MCP Core classes.
|
||||
@@ -388,6 +402,14 @@ class ModelListCore(BaseCore, Generic[L]):
|
||||
columns_to_load.append(dependency)
|
||||
|
||||
self._validate_order_column(order_column)
|
||||
# Resolve humanized/computed aliases (e.g. changed_on_delta_humanized)
|
||||
# to the real column they're derived from. Must happen after
|
||||
# validation (which checks against the advertised sortable_columns,
|
||||
# including the alias) and before the DAO call, since DAO.list()
|
||||
# sorts via `getattr(model, order_column)` and would receive a
|
||||
# Python property/method instead of a SQL column otherwise.
|
||||
if order_column is not None and order_column in self._sortable_columns:
|
||||
order_column = _ORDER_COLUMN_ALIASES.get(order_column, order_column)
|
||||
|
||||
deleted_state_bound = self._build_deleted_state_filter(deleted_state)
|
||||
if deleted_state_bound is not None:
|
||||
|
||||
@@ -41,6 +41,7 @@ from superset.exceptions import SupersetException, SupersetSecurityException
|
||||
from superset.extensions import event_logger, stats_logger_manager
|
||||
from superset.mcp_service.auth import (
|
||||
_get_app_context_manager,
|
||||
_mcp_user_id_var,
|
||||
get_user_from_request,
|
||||
is_tool_visible_to_current_user,
|
||||
MCPNoAuthSourceError,
|
||||
@@ -625,6 +626,24 @@ class LoggingMiddleware(Middleware):
|
||||
success = False
|
||||
raise
|
||||
finally:
|
||||
# user_id was captured before call_next() ran the tool, i.e.
|
||||
# before the @tool auth decorator (superset/mcp_service/auth.py)
|
||||
# resolves the user. It sets g.user on a per-call app context
|
||||
# that _get_app_context_manager() pushes and pops around the
|
||||
# tool's execution (see its docstring), so g.user/get_user_id()
|
||||
# are back to their pre-call state by the time we get here —
|
||||
# re-reading get_user_id() would still yield the stale value.
|
||||
# _mcp_user_id_var is a plain ContextVar (not tied to that Flask
|
||||
# app-context lifecycle) that _setup_user_context() sets before
|
||||
# the context pops, so it survives to this point.
|
||||
resolved_user_id = _mcp_user_id_var.get(None)
|
||||
if resolved_user_id is not None:
|
||||
user_id = resolved_user_id
|
||||
# Reset so a later on_call_tool/on_message in the same asyncio
|
||||
# task (e.g. an unprotected tool or resource/prompt read that
|
||||
# never calls _setup_user_context()) doesn't inherit this call's
|
||||
# resolved user id.
|
||||
_mcp_user_id_var.set(None)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
self._log_call_tool_result(
|
||||
context=context,
|
||||
@@ -666,34 +685,44 @@ class LoggingMiddleware(Middleware):
|
||||
self._extract_context_info(context)
|
||||
)
|
||||
try:
|
||||
with _get_app_context_manager():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log mcp_message event: %s", log_error)
|
||||
logger.info(
|
||||
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
||||
getattr(context.message, "name", None),
|
||||
agent_id,
|
||||
user_id,
|
||||
context.method,
|
||||
)
|
||||
return await call_next(context)
|
||||
return await call_next(context)
|
||||
finally:
|
||||
# See the matching comment in on_call_tool: g.user/get_user_id()
|
||||
# are stale here because the per-call app context has already
|
||||
# been popped. _mcp_user_id_var survives it.
|
||||
resolved_user_id = _mcp_user_id_var.get(None)
|
||||
if resolved_user_id is not None:
|
||||
user_id = resolved_user_id
|
||||
# See the matching reset in on_call_tool.
|
||||
_mcp_user_id_var.set(None)
|
||||
try:
|
||||
with _get_app_context_manager():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log mcp_message event: %s", log_error)
|
||||
logger.info(
|
||||
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
||||
getattr(context.message, "name", None),
|
||||
agent_id,
|
||||
user_id,
|
||||
context.method,
|
||||
)
|
||||
|
||||
|
||||
class StructuredContentStripperMiddleware(Middleware):
|
||||
|
||||
@@ -802,6 +802,64 @@ def _truncate_rows_field(
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_chart_query_results(
|
||||
data: Dict[str, Any], token_limit: int, advice: str
|
||||
) -> list[str] | None:
|
||||
"""Apply one response-wide row cap to every result of a multi-query chart."""
|
||||
from superset.utils import json as utils_json
|
||||
|
||||
query_results = data.get("query_results")
|
||||
if not isinstance(query_results, list) or not query_results:
|
||||
return None
|
||||
|
||||
row_lists = [data.get("data", [])]
|
||||
row_lists.extend(
|
||||
result.get("data", [])
|
||||
for result in query_results
|
||||
if isinstance(result, dict) and isinstance(result.get("data"), list)
|
||||
)
|
||||
originals = [list(rows) for rows in row_lists]
|
||||
if not any(originals):
|
||||
return None
|
||||
|
||||
original_count = sum(len(rows) for rows in originals[1:])
|
||||
data["_response_truncated"] = True
|
||||
data["_truncation_notes"] = [
|
||||
f"Result truncated: {original_count} of {original_count} rows returned "
|
||||
f"across multiple queries "
|
||||
f"(limit ~{token_limit:,} tokens). {advice}"
|
||||
]
|
||||
|
||||
lo, hi = 0, max(len(rows) for rows in originals)
|
||||
while lo < hi:
|
||||
cap = (lo + hi + 1) // 2
|
||||
for rows, original in zip(row_lists, originals, strict=False):
|
||||
rows[:] = original[:cap]
|
||||
if estimate_token_count(utils_json.dumps(data)) <= token_limit:
|
||||
lo = cap
|
||||
else:
|
||||
hi = cap - 1
|
||||
|
||||
cap = max(lo, 1)
|
||||
for rows, original in zip(row_lists, originals, strict=False):
|
||||
rows[:] = original[:cap]
|
||||
kept_count = sum(len(rows) for rows in row_lists[1:])
|
||||
if kept_count >= original_count:
|
||||
del data["_response_truncated"]
|
||||
del data["_truncation_notes"]
|
||||
return None
|
||||
|
||||
data["row_count"] = len(row_lists[0])
|
||||
for result in query_results:
|
||||
if isinstance(result, dict) and isinstance(result.get("data"), list):
|
||||
result["row_count"] = len(result["data"])
|
||||
data["_truncation_notes"] = [
|
||||
f"Result truncated: {kept_count} of {original_count} rows returned "
|
||||
f"across multiple queries (limit ~{token_limit:,} tokens). {advice}"
|
||||
]
|
||||
return data["_truncation_notes"]
|
||||
|
||||
|
||||
def _truncate_csv_data_field(
|
||||
data: Dict[str, Any],
|
||||
token_limit: int,
|
||||
@@ -900,7 +958,9 @@ def truncate_query_result(
|
||||
if estimate_token_count(utils_json.dumps(data)) <= token_limit:
|
||||
return data, False, []
|
||||
|
||||
notes = _truncate_rows_field(data, row_field, token_limit, advice)
|
||||
notes = _truncate_chart_query_results(data, token_limit, advice)
|
||||
if notes is None:
|
||||
notes = _truncate_rows_field(data, row_field, token_limit, advice)
|
||||
if notes is None:
|
||||
notes = _truncate_csv_data_field(data, token_limit, advice)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user