Compare commits

...

11 Commits

Author SHA1 Message Date
Claude Code
4debd2d01a fix(legacy-preset-chart-nvd3): sanitize tooltip HTML built from chart data
Several tooltip generators in this module build HTML strings from chart
data and return them to be rendered via D3 `.html()`, but did not run the
result through DOMPurify the way the sibling generators in the same file
already do. Apply `dompurify.sanitize()` to the returned HTML in
`generateBubbleTooltipContent`, `generateMultiLineTooltipContent`, and the
`tipFactory` annotation tooltip callback so data-derived values are
rendered as text rather than markup.

Adds regression tests asserting that script/handler markup in the
data-derived fields is stripped from the generated tooltip HTML.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:05:03 -07:00
Amin Ghadersohi
87be424f9c feat(mcp): add list and get tools for row level security and plugins (#40347) 2026-05-30 10:41:12 -04:00
Kasia
4d95a8d034 feat(listview): compact filter pills with popover for CRUD views (#40169) 2026-05-30 10:30:40 +02:00
Đỗ Trọng Hải
2d6e68b5f2 fix(ci): remove deprecated ephemeral env workflows + resolve fixable GHA-related security issues (#40121)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-30 14:09:46 +07:00
Evan Rusackas
2e7bec3646 chore(ci): harden GitHub Actions workflows per static analysis (#40545)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 13:13:43 +07:00
Evan Rusackas
f4787a4f25 chore(deps): bump ws to 8.20.1 in docs (#40538)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 13:08:13 +07:00
Evan Rusackas
fa4e571db5 chore(deps): force uuid 11.1.1 in docs (#40542)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 13:04:31 +07:00
Evan Rusackas
838ee27c29 chore(deps): bump protobuf to 5.29.6 and google-cloud-bigquery-storage to 2.26.0 (#40537)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 11:49:15 +07:00
Evan Rusackas
7f54b0b13d test(database): regression test for sqla engine creation (#27897) (#40237)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:47:49 -07:00
Evan Rusackas
f165c3fa78 fix(ci): grant security-events write to GHA validator workflow (#40539)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-29 21:46:54 -07:00
Evan Rusackas
8c6271e9ff chore(deps): bump urllib3, Mako, and python-multipart for high-severity CVEs (#40534)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 11:39:33 +07:00
84 changed files with 4537 additions and 1293 deletions

View File

@@ -36,7 +36,7 @@ runs:
echo "PYTHON_VERSION=${{ inputs.python-version }}" >> $GITHUB_ENV
fi
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: ${{ inputs.cache }}

View File

@@ -23,6 +23,7 @@ runs:
if: ${{ inputs.from-npm == 'false' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
repository: apache-superset/supersetbot
path: supersetbot

View File

@@ -10,7 +10,7 @@ updates:
schedule:
interval: "daily"
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
ignore:
@@ -59,7 +59,7 @@ updates:
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "pip"
@@ -76,7 +76,7 @@ updates:
- pip
- dependabot
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: ".github/actions"
@@ -85,7 +85,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/docs/"
@@ -110,7 +110,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-websocket/"
@@ -121,7 +121,7 @@ updates:
- dependabot
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-websocket/utils/client-ws-app/"
@@ -133,7 +133,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
# Now for all of our plugins and packages!
@@ -147,7 +147,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
@@ -159,7 +159,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
@@ -171,7 +171,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
@@ -186,7 +186,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
@@ -198,7 +198,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
@@ -210,7 +210,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
@@ -222,7 +222,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
@@ -234,7 +234,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-table/"
@@ -249,7 +249,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
@@ -261,7 +261,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
@@ -273,7 +273,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
@@ -285,7 +285,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
@@ -297,7 +297,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
@@ -309,7 +309,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
@@ -321,7 +321,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
@@ -333,7 +333,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
@@ -345,7 +345,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
@@ -357,7 +357,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
@@ -373,7 +373,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/generator-superset/"
@@ -385,7 +385,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
@@ -397,7 +397,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-core/"
@@ -414,7 +414,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-switchboard/"
@@ -426,4 +426,4 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 5
default-days: 7

View File

@@ -1,43 +0,0 @@
name: Cancel Duplicates
on:
workflow_run:
workflows:
- "Miscellaneous"
types:
- requested
jobs:
cancel-duplicate-runs:
name: Cancel duplicate workflow runs
runs-on: ubuntu-24.04
permissions:
actions: write
contents: read
steps:
- name: Check number of queued tasks
id: check_queued
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPO: ${{ github.repository }}
run: |
get_count() {
echo $(curl -s -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$GITHUB_REPO/actions/runs?status=$1" | \
jq ".total_count")
}
count=$(( `get_count queued` + `get_count in_progress` ))
echo "Found $count unfinished jobs."
echo "count=$count" >> $GITHUB_OUTPUT
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
if: steps.check_queued.outputs.count >= 20
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Cancel duplicate workflow runs
if: steps.check_queued.outputs.count >= 20
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
pip install click requests typing_extensions python-dateutil
python ./scripts/cancel_github_workflows.py

View File

@@ -26,6 +26,8 @@ jobs:
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check and notify
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:

View File

@@ -6,6 +6,9 @@ on:
pull_request_review_comment:
types: [created]
permissions:
contents: read
jobs:
check-permissions:
if: |
@@ -75,6 +78,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude PR Action

View File

@@ -32,6 +32,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check

View File

@@ -28,6 +28,8 @@ jobs:
steps:
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: "Dependency Review"
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
continue-on-error: true
@@ -50,6 +52,8 @@ jobs:
steps:
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Setup Python
uses: ./.github/actions/setup-backend/

View File

@@ -34,6 +34,8 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-embedded-sdk/.nvmrc'

View File

@@ -22,6 +22,8 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-embedded-sdk/.nvmrc'

View File

@@ -1,83 +0,0 @@
name: Cleanup ephemeral envs (PR close) [DEPRECATED]
# ⚠️ DEPRECATION NOTICE ⚠️
# This workflow is deprecated and will be removed in a future version.
# The new Superset Showtime workflow handles cleanup automatically.
# See .github/workflows/showtime.yml and showtime-cleanup.yml for replacements.
# Migration guide: https://github.com/mistercrunch/superset-showtime
on:
pull_request_target:
types: [closed]
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
steps:
- name: "Check for secrets"
id: check
shell: bash
run: |
if [ -n "${AWS_ACCESS_KEY_ID}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
AWS_ACCESS_KEY_ID: ${{ (secrets.AWS_ACCESS_KEY_ID != '' && secrets.AWS_SECRET_ACCESS_KEY != '') || '' }}
ephemeral-env-cleanup:
needs: config
if: needs.config.outputs.has-secrets
name: Cleanup ephemeral envs
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Describe ECS service
id: describe-services
run: |
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${{ github.event.number }}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
- name: Delete ECS service
if: steps.describe-services.outputs.active == 'true'
id: delete-service
run: |
aws ecs delete-service \
--cluster superset-ci \
--service pr-${{ github.event.number }}-service \
--force
- name: Login to Amazon ECR
if: steps.describe-services.outputs.active == 'true'
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Delete ECR image tag
if: steps.describe-services.outputs.active == 'true'
id: delete-image-tag
run: |
aws ecr batch-delete-image \
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
--repository-name superset-ci \
--image-ids imageTag=pr-${{ github.event.number }}
- name: Comment (success)
if: steps.describe-services.outputs.active == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
github.rest.issues.createComment({
issue_number: ${{ github.event.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ **DEPRECATED WORKFLOW** - Ephemeral environment shutdown and build artifacts deleted. Please migrate to the new Superset Showtime system for future PRs.'
})

View File

@@ -1,350 +0,0 @@
name: Ephemeral env workflow [DEPRECATED]
# ⚠️ DEPRECATION NOTICE ⚠️
# This workflow is deprecated and will be removed in a future version.
# Please use the new Superset Showtime workflow instead:
# - Use label "🎪 trigger-start" instead of "testenv-up"
# - Showtime provides better reliability and easier management
# - See .github/workflows/showtime.yml for the replacement
# - Migration guide: https://github.com/mistercrunch/superset-showtime
# Example manual trigger:
# gh workflow run ephemeral-env.yml --ref fix_ephemerals --field label_name="testenv-up" --field issue_number=666
on:
pull_request_target:
types:
- labeled
workflow_dispatch:
inputs:
label_name:
description: 'Label name to simulate label-based /testenv trigger'
required: true
default: 'testenv-up'
issue_number:
description: 'Issue or PR number'
required: true
jobs:
ephemeral-env-label:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-label
cancel-in-progress: true
name: Evaluate ephemeral env label trigger
runs-on: ubuntu-24.04
permissions:
pull-requests: write
outputs:
slash-command: ${{ steps.eval-label.outputs.result }}
feature-flags: ${{ steps.eval-feature-flags.outputs.result }}
sha: ${{ steps.get-sha.outputs.sha }}
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Check for the "testenv-up" label
id: eval-label
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
LABEL_NAME="${INPUT_LABEL_NAME}"
else
LABEL_NAME="${{ github.event.label.name }}"
fi
echo "Evaluating label: $LABEL_NAME"
if [[ "$LABEL_NAME" == "testenv-up" ]]; then
echo "result=up" >> $GITHUB_OUTPUT
else
echo "result=noop" >> $GITHUB_OUTPUT
fi
env:
INPUT_LABEL_NAME: ${{ github.event.inputs.label_name }}
- name: Get event SHA
id: get-sha
if: steps.eval-label.outputs.result == 'up'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
let prSha;
// If event is workflow_dispatch, use the issue_number from inputs
if (context.eventName === "workflow_dispatch") {
const prNumber = "${{ github.event.inputs.issue_number }}";
if (!prNumber) {
console.log("No PR number found.");
return;
}
// Fetch PR details using the provided issue_number
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
prSha = pr.head.sha;
} else {
// If it's not workflow_dispatch, use the PR head sha from the event
prSha = context.payload.pull_request.head.sha;
}
console.log(`PR SHA: ${prSha}`);
core.setOutput("sha", prSha);
- name: Looking for feature flags in PR description
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: eval-feature-flags
if: steps.eval-label.outputs.result == 'up'
with:
script: |
const description = context.payload.pull_request
? context.payload.pull_request.body || ''
: context.payload.inputs.pr_description || '';
const pattern = /FEATURE_(\w+)=(\w+)/g;
let results = [];
[...description.matchAll(pattern)].forEach(match => {
const config = {
name: `SUPERSET_FEATURE_${match[1]}`,
value: match[2],
};
results.push(config);
});
return results;
- name: Reply with confirmation comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: steps.eval-label.outputs.result == 'up'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const action = '${{ steps.eval-label.outputs.result }}';
const user = context.actor;
const runId = context.runId;
const workflowUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const issueNumber = context.payload.pull_request
? context.payload.pull_request.number
: context.payload.inputs.issue_number;
if (!issueNumber) {
throw new Error("Issue number is not available.");
}
const body = `⚠️ **DEPRECATED WORKFLOW** ⚠️\n\n@${user} This workflow is deprecated! Please use the new **Superset Showtime** system instead:\n\n` +
`- Replace "testenv-up" label with "🎪 trigger-start"\n` +
`- Better reliability and easier management\n` +
`- See https://github.com/mistercrunch/superset-showtime for details\n\n` +
`Processing your ephemeral environment request [here](${workflowUrl}). Action: **${action}**.` +
` More information on [how to use or configure ephemeral environments]` +
`(https://superset.apache.org/docs/contributing/howtos/#github-ephemeral-environments)`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
ephemeral-docker-build:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-build
cancel-in-progress: true
needs: ephemeral-env-label
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
name: ephemeral-docker-build
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ needs.ephemeral-env-label.outputs.sha }} : ${{steps.get-sha.outputs.sha}} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.ephemeral-env-label.outputs.sha }}
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
build: "true"
install-docker-compose: "false"
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Build ephemeral env image
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
supersetbot docker \
--push \
--load \
--preset ci \
--platform linux/amd64 \
--context-ref "$RELEASE" \
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Load, tag and push image to ECR
id: push-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: superset-ci
IMAGE_TAG: apache/superset:${{ needs.ephemeral-env-label.outputs.sha }}-ci
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-$PR_NUMBER-ci
docker push -a $ECR_REGISTRY/$ECR_REPOSITORY
ephemeral-env-up:
needs: [ephemeral-env-label, ephemeral-docker-build]
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
name: Spin up an ephemeral environment
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Check target image exists in ECR
id: check-image
continue-on-error: true
env:
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecr describe-images \
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
--repository-name superset-ci \
--image-ids imageTag=pr-$PR_NUMBER-ci
- name: Fail on missing container image
if: steps.check-image.outcome == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
script: |
const errMsg = '@${{ github.event.comment.user.login }} Container image not yet published for this PR. Please try again when build is complete.';
github.rest.issues.createComment({
issue_number: ${{ github.event.inputs.issue_number || github.event.pull_request.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: errMsg
});
core.setFailed(errMsg);
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@6853cfae8c3a7d978fbf68b5a55453395541dfbb # v1
with:
task-definition: .github/workflows/ecs-task-definition.json
container-name: superset-ci
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-ci
- name: Update env vars in the Amazon ECS task definition
run: |
cat <<< "$(jq '.containerDefinitions[0].environment += ${{ needs.ephemeral-env-label.outputs.feature-flags }}' < ${{ steps.task-def.outputs.task-definition }})" > ${{ steps.task-def.outputs.task-definition }}
- name: Describe ECS service
id: describe-services
run: |
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${INPUT_ISSUE_NUMBER}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
env:
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
- name: Create ECS service
id: create-service
if: steps.describe-services.outputs.active != 'true'
env:
ECR_SUBNETS: subnet-0e15a5034b4121710,subnet-0e8efef4a72224974
ECR_SECURITY_GROUP: sg-092ff3a6ae0574d91
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecs create-service \
--cluster superset-ci \
--service-name pr-$PR_NUMBER-service \
--task-definition superset-ci \
--launch-type FARGATE \
--desired-count 1 \
--platform-version LATEST \
--network-configuration "awsvpcConfiguration={subnets=[$ECR_SUBNETS],securityGroups=[$ECR_SECURITY_GROUP],assignPublicIp=ENABLED}" \
--tags key=pr,value=$PR_NUMBER key=github_user,value=${{ github.actor }}
- name: Deploy Amazon ECS task definition
id: deploy-task
uses: aws-actions/amazon-ecs-deploy-task-definition@a310a830f5c14e583e35d84e4e1ec7dd177c3c9c # v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service
cluster: superset-ci
wait-for-service-stability: true
wait-for-minutes: 10
- name: List tasks
id: list-tasks
run: |
echo "task=$(aws ecs list-tasks --cluster superset-ci --service-name pr-${INPUT_ISSUE_NUMBER}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
env:
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
- name: Get network interface
id: get-eni
run: |
echo "eni=$(aws ecs describe-tasks --cluster superset-ci --tasks ${{ steps.list-tasks.outputs.task }} | jq '.tasks[0].attachments[0].details | map(select(.name=="networkInterfaceId"))[0].value')" >> $GITHUB_OUTPUT
- name: Get public IP
id: get-ip
run: |
echo "ip=$(aws ec2 describe-network-interfaces --network-interface-ids ${{ steps.get-eni.outputs.eni }} | jq -r '.NetworkInterfaces | first | .Association.PublicIp')" >> $GITHUB_OUTPUT
- name: Comment (success)
if: ${{ success() }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `@${{ github.actor }} Ephemeral environment spinning up at http://${{ steps.get-ip.outputs.ip }}:8080. Credentials are 'admin'/'admin'. Please allow several minutes for bootstrapping and startup.`
});
- name: Comment (failure)
if: ${{ failure() }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@${{ github.event.inputs.user_login || github.event.comment.user.login }} Ephemeral environment creation failed. Please check the Actions logs for details.'
})

View File

@@ -16,6 +16,11 @@ jobs:
validate-all-ghas:
runs-on: ubuntu-24.04
permissions:
contents: read
# Required for the zizmor action to upload its SARIF results to
# GitHub code scanning (advanced-security is enabled by default).
security-events: write
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@@ -9,7 +9,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-24.04
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
sync-labels: true

View File

@@ -20,7 +20,9 @@ jobs:
- name: Check for latest tag
id: latest-tag
run: |
source ./scripts/tag_latest_release.sh $(echo ${{ github.event.release.tag_name }}) --dry-run
source ./scripts/tag_latest_release.sh $(echo ${GITHUB_EVENT_RELEASE_TAG_NAME}) --dry-run
env:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
- name: Configure Git
run: |

View File

@@ -6,6 +6,9 @@ on:
- "master"
- "[0-9].[0-9]*"
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -27,9 +30,12 @@ jobs:
if: needs.config.outputs.has-secrets
name: Bump version and publish package(s)
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# pulls all commits (needed for lerna / semantic release to correctly version)
fetch-depth: 0
- name: Get tags and filter trigger tags

View File

@@ -102,7 +102,7 @@ jobs:
- name: Install Superset Showtime
if: steps.auth.outputs.authorized == 'true'
run: |
echo "::notice::Maintainer ${{ github.actor }} triggered deploy for PR ${PULL_REQUEST_NUMBER}"
echo "::notice::Maintainer ${GITHUB_ACTOR} triggered deploy for PR ${PULL_REQUEST_NUMBER}"
pip install --upgrade superset-showtime
showtime version

View File

@@ -27,6 +27,9 @@ concurrency:
group: docs-deploy-asf-site
cancel-in-progress: true
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -16,6 +16,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_sha || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
linkinator:
# See docs here: https://github.com/marketplace/actions/linkinator
@@ -25,6 +28,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3

View File

@@ -53,7 +53,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: steps.check.outputs.superset-extensions-cli
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
file: ./coverage.xml
flags: superset-extensions-cli

View File

@@ -16,6 +16,9 @@ concurrency:
env:
TAG: apache/superset:GHA-${{ github.run_id }}
permissions:
contents: read
jobs:
frontend-build:
runs-on: ubuntu-24.04
@@ -128,7 +131,7 @@ jobs:
run: npx nyc merge coverage/ merged-output/coverage-summary.json
- name: Upload Code Coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: javascript
use_oidc: true

View File

@@ -70,7 +70,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,mysql
verbose: true
@@ -164,7 +164,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,postgres
verbose: true
@@ -219,7 +219,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,sqlite
verbose: true

View File

@@ -79,7 +79,7 @@ jobs:
run: |
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,presto
verbose: true
@@ -150,7 +150,7 @@ jobs:
pip install -e .[hive]
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,hive
verbose: true

View File

@@ -56,7 +56,7 @@ jobs:
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,unit
verbose: true

View File

@@ -21,6 +21,9 @@ on:
options:
- 'true'
- 'false'
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -42,6 +45,8 @@ jobs:
if: needs.config.outputs.has-secrets
name: docker-release
runs-on: ubuntu-24.04
permissions:
contents: write
strategy:
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
@@ -51,6 +56,7 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Setup Docker Environment
@@ -77,8 +83,9 @@ jobs:
INPUT_RELEASE: ${{ github.event.inputs.release }}
INPUT_FORCE_LATEST: ${{ github.event.inputs.force-latest }}
INPUT_GIT_REF: ${{ github.event.inputs.git-ref }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
RELEASE="${{ github.event.release.tag_name }}"
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
FORCE_LATEST=""
EVENT="${{github.event_name}}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -114,6 +121,7 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Use Node.js 20
@@ -128,11 +136,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_RELEASE: ${{ github.event.inputs.release }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
export GITHUB_ACTOR=""
git fetch --all --tags
git checkout master
RELEASE="${{ github.event.release.tag_name }}"
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# in the case of a manually-triggered run, read release from input
RELEASE="${INPUT_RELEASE}"

View File

@@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Welcome Message
uses: actions/first-interaction@v3
uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
with:
repo_token: ${{ github.token }}
issue_message: |-

View File

@@ -131,7 +131,8 @@
"swagger-client": "3.37.3",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"yaml": "1.10.3"
"yaml": "1.10.3",
"uuid": "11.1.1"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}

View File

@@ -14721,15 +14721,10 @@ utils-merge@1.0.1:
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@8.3.2, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
"uuid@^11.1.0 || ^12 || ^13 || ^14.0.0":
version "14.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
uuid@11.1.1, uuid@8.3.2, "uuid@^11.1.0 || ^12 || ^13 || ^14.0.0", uuid@^8.3.2:
version "11.1.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad"
integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==
uvu@^0.5.0:
version "0.5.6"
@@ -15134,9 +15129,9 @@ ws@^7.3.1:
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.18.0, ws@^8.2.3:
version "8.18.3"
resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
version "8.20.1"
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
wsl-utils@^0.1.0:
version "0.1.0"

View File

@@ -208,7 +208,7 @@ kombu==5.5.3
# via celery
limits==5.1.0
# via flask-limiter
mako==1.3.11
mako==1.3.12
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -451,7 +451,7 @@ tzdata==2025.2
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.6.3
urllib3==2.7.0
# via
# -r requirements/base.in
# requests

View File

@@ -346,6 +346,7 @@ google-auth==2.43.0
# google-api-core
# google-auth-oauthlib
# google-cloud-bigquery
# google-cloud-bigquery-storage
# google-cloud-core
# pandas-gbq
# pydata-google-auth
@@ -360,7 +361,7 @@ google-cloud-bigquery==3.27.0
# apache-superset
# pandas-gbq
# sqlalchemy-bigquery
google-cloud-bigquery-storage==2.19.1
google-cloud-bigquery-storage==2.26.0
# via pandas-gbq
google-cloud-core==2.4.1
# via google-cloud-bigquery
@@ -506,7 +507,7 @@ limits==5.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
mako==1.3.11
mako==1.3.12
# via
# -c requirements/base-constraint.txt
# alembic
@@ -701,7 +702,7 @@ proto-plus==1.25.0
# via
# google-api-core
# google-cloud-bigquery-storage
protobuf==4.25.8
protobuf==5.29.6
# via
# google-api-core
# google-cloud-bigquery-storage
@@ -839,7 +840,7 @@ python-dotenv==1.2.2
# pydantic-settings
python-ldap==3.4.4
# via apache-superset
python-multipart==0.0.20
python-multipart==0.0.29
# via mcp
pytz==2025.2
# via
@@ -1071,7 +1072,7 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.6.3
urllib3==2.7.0
# via
# -c requirements/base-constraint.txt
# botocore

View File

@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
echo "$output" >&2
exit 1
}
[ -n "$output" ] && echo "$output"
if [ -n "$output" ]; then echo "$output"; fi
else
echo "No JavaScript/TypeScript files to lint"
fi

View File

@@ -162,7 +162,7 @@ export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) {
tooltip += '</tbody></table>';
return tooltip;
return dompurify.sanitize(tooltip);
}
export function generateTimePivotTooltip(d, xFormatter, yFormatter) {
@@ -223,7 +223,7 @@ export function generateBubbleTooltipContent({
s += createHTMLRow(getLabel(sizeField), sizeFormatter(point.size));
s += '</table>';
return s;
return dompurify.sanitize(s);
}
// shouldRemove indicates whether the nvtooltips should be removed from the DOM
@@ -287,9 +287,11 @@ export function tipFactory(layer) {
? layer.descriptionColumns.map(c => d[c])
: Object.values(d);
return `<div><strong>${title}</strong></div><br/><div>${body.join(
', ',
)}</div>`;
return dompurify.sanitize(
`<div><strong>${title}</strong></div><br/><div>${body.join(
', ',
)}</div>`,
);
});
}

View File

@@ -26,6 +26,9 @@ import {
computeYDomain,
getTimeOrNumberFormatter,
formatLabel,
generateBubbleTooltipContent,
generateMultiLineTooltipContent,
tipFactory,
} from '../src/utils';
const DATA = [
@@ -181,4 +184,61 @@ describe('nvd3/utils', () => {
]);
});
});
describe('tooltip HTML sanitization', () => {
const identity = (v: unknown) => v;
test('generateBubbleTooltipContent strips scripts from entity/group', () => {
const html = generateBubbleTooltipContent({
point: {
name: '<img src=x onerror="alert(1)">',
group: '<script>alert(2)</script>',
color: 'red',
x: 1,
y: 2,
size: 3,
},
entity: 'name',
xField: 'x',
yField: 'y',
sizeField: 'size',
xFormatter: identity,
yFormatter: identity,
sizeFormatter: identity,
});
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script>');
});
test('generateMultiLineTooltipContent strips scripts from series keys', () => {
const html = generateMultiLineTooltipContent(
{
value: 'x',
series: [
{ key: '<img src=x onerror="alert(1)">', color: 'red', value: 1 },
],
},
identity,
[identity],
);
expect(html).not.toContain('onerror');
});
test('tipFactory strips scripts from annotation data values', () => {
const tip = tipFactory({
titleColumn: 'title',
name: 'layer',
descriptionColumns: ['desc'],
});
const html = tip.html()({
title: '<img src=x onerror="alert(1)">',
desc: '<script>alert(2)</script>',
});
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script>');
});
});
});

View File

@@ -152,3 +152,33 @@ export async function selectOption(option: string, selectName?: string) {
);
await userEvent.click(item);
}
/**
* Select an option from a compact pill filter (new UI that replaced comboboxes).
* Clicks the pill button matching the label, then clicks the option in the panel.
*/
export async function selectPillOption(option: string, pillLabel?: string) {
let pill: HTMLElement;
if (pillLabel) {
// Find the pill whose text content includes the label
pill = await waitFor(() => {
const pills = screen.getAllByTestId('compact-filter-pill');
const match = pills.find(p => p.textContent?.includes(pillLabel));
if (!match)
throw new Error(`Could not find pill with label "${pillLabel}"`);
return match;
});
} else {
pill = await screen.findByTestId('compact-filter-pill');
}
await userEvent.click(pill);
// Wait for the option list to appear and click the item
const item = await waitFor(() => {
const listbox = document.querySelector('[role="listbox"]');
if (!listbox) throw new Error('No listbox found');
const opt = within(listbox as HTMLElement).getByText(option);
if (!opt) throw new Error(`Option "${option}" not found`);
return opt;
});
await userEvent.click(item);
}

View File

@@ -817,8 +817,11 @@ export function exploreJSON(
),
);
(queriesResponse as QueryData[]).forEach(response => {
if (response.warning) {
dispatch(addWarningToast(response.warning, { noDuplicate: true }));
const { warning } = response as QueryData & {
warning?: string | null;
};
if (warning) {
dispatch(addWarningToast(warning, { noDuplicate: true }));
}
});
return dispatch(

View File

@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { CardSortSelect } from './CardSortSelect';
const options = [
{ desc: false, id: 'title', label: 'Alphabetical', value: 'alphabetical' },
{
desc: true,
id: 'changed_on',
label: 'Recently modified',
value: 'recently_modified',
},
{
desc: false,
id: 'changed_on',
label: 'Least recently modified',
value: 'least_recently_modified',
},
];
test('pill always shows "Sort" label with no value suffix and no clear button', () => {
render(
<CardSortSelect
options={options}
onChange={jest.fn()}
initialSort={[{ id: 'title', desc: false }]}
/>,
);
expect(screen.getByText('Sort')).toBeInTheDocument();
expect(screen.queryByText(/sort.*alphabetical/i)).not.toBeInTheDocument();
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('no clear button even when a non-default sort is active', () => {
render(
<CardSortSelect
options={options}
onChange={jest.fn()}
initialSort={[{ id: 'changed_on', desc: true }]}
/>,
);
expect(screen.getByText('Sort')).toBeInTheDocument();
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
});
test('clicking a sort option from the panel calls onChange with the correct id and desc', async () => {
const onChange = jest.fn();
render(
<CardSortSelect
options={options}
onChange={onChange}
initialSort={[{ id: 'title', desc: false }]}
/>,
);
await userEvent.click(screen.getByTestId('compact-filter-pill'));
expect(screen.getByText('Recently modified')).toBeInTheDocument();
await userEvent.click(screen.getByText('Recently modified'));
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: true }]);
// Pill label stays "Sort" — value is in tooltip, not the label
expect(screen.getByText('Sort')).toBeInTheDocument();
});
test('selecting a different option from the panel calls onChange with correct args', async () => {
const onChange = jest.fn();
render(
<CardSortSelect
options={options}
onChange={onChange}
initialSort={[{ id: 'title', desc: false }]}
/>,
);
await userEvent.click(screen.getByTestId('compact-filter-pill'));
await userEvent.click(screen.getByText('Least recently modified'));
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: false }]);
});

View File

@@ -16,20 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useMemo } from 'react';
import { useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { FormLabel, Select } from '@superset-ui/core/components';
import { SELECT_WIDTH } from './utils';
import type { SelectOption } from './types';
import { CardSortSelectOption, SortColumn } from './types';
const SortContainer = styled.div`
display: inline-flex;
font-size: ${({ theme }) => theme.fontSizeSM}px;
align-items: center;
text-align: left;
width: ${SELECT_WIDTH}px;
`;
import CompactFilterTrigger from './Filters/CompactFilterTrigger';
import CompactSelectPanel from './Filters/CompactSelectPanel';
import type { FilterHandler } from './Filters/types';
interface CardViewSelectSortProps {
onChange: (value: SortColumn[]) => void;
@@ -42,6 +35,8 @@ export const CardSortSelect = ({
onChange,
options,
}: CardViewSelectSortProps) => {
const panelRef = useRef<FilterHandler>(null);
const defaultSort =
(initialSort &&
options.find(
@@ -50,44 +45,41 @@ export const CardSortSelect = ({
)) ||
options[0];
const [value, setValue] = useState({
const [currentValue, setCurrentValue] = useState<SelectOption>({
label: defaultSort.label,
value: defaultSort.value,
});
const formattedOptions = useMemo(
() => options.map(option => ({ label: option.label, value: option.value })),
[options],
);
const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
const handleOnChange = (selected: { label: string; value: string }) => {
setValue(selected);
const originalOption = options.find(
({ value }) => value === selected.value,
);
if (originalOption) {
const sortBy = [
{
id: originalOption.id,
desc: originalOption.desc,
},
];
onChange(sortBy);
const handleSelect = (option: SelectOption | undefined) => {
if (!option) return;
const original = options.find(o => o.value === option.value);
if (original) {
setCurrentValue({ label: original.label, value: original.value });
onChange([{ id: original.id, desc: original.desc }]);
}
};
return (
<SortContainer>
<Select
ariaLabel={t('Sort')}
header={<FormLabel>{t('Sort')}</FormLabel>}
labelInValue
onChange={handleOnChange}
options={formattedOptions}
showSearch
value={value}
data-test="card-sort-select"
/>
</SortContainer>
<span data-test="card-sort-select">
<CompactFilterTrigger
label={t('Sort')}
hasValue={false}
onClear={() => {}}
tooltipTitle={String(currentValue.label)}
>
{({ isOpen, onClose }) => (
<CompactSelectPanel
ref={panelRef}
selects={selectOptions}
value={currentValue}
onSelect={handleSelect}
isOpen={isOpen}
onClose={onClose}
/>
)}
</CompactFilterTrigger>
</span>
);
};

View File

@@ -0,0 +1,145 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import CompactFilterTrigger from './CompactFilterTrigger';
// Base props without children — pass children as JSX to avoid no-children-prop lint rule.
const baseProps = {
label: 'Owner',
hasValue: false,
onClear: jest.fn(),
};
const defaultChildren = jest.fn(() => (
<div data-testid="filter-content">Filter content</div>
));
function renderTrigger(
props: Partial<
typeof baseProps & {
hasValue: boolean;
tooltipTitle?: string;
popupType?: 'listbox' | 'dialog';
}
> = {},
children = defaultChildren,
) {
return render(
<CompactFilterTrigger {...baseProps} {...props}>
{children}
</CompactFilterTrigger>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders the label', () => {
renderTrigger();
expect(screen.getByText('Owner')).toBeInTheDocument();
});
test('renders as inactive pill with down chevron when hasValue is false', () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
// No clear button when inactive
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
});
test('renders active state with clear icon when hasValue is true', () => {
renderTrigger({ hasValue: true });
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
});
test('clear icon has descriptive aria-label matching the filter name', () => {
renderTrigger({ hasValue: true });
const clearIcon = screen.getByTestId('compact-filter-clear');
expect(clearIcon).toHaveAttribute('aria-label', 'Clear Owner filter');
});
test('clear icon is rendered inside the pill button', () => {
renderTrigger({ hasValue: true });
const pill = screen.getByTestId('compact-filter-pill');
const clearIcon = screen.getByTestId('compact-filter-clear');
expect(pill).toContainElement(clearIcon);
});
test('toggles aria-expanded when pill is clicked', async () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'true');
});
test('calls onClear when clear icon is clicked', async () => {
const onClear = jest.fn();
renderTrigger({ hasValue: true, onClear } as any);
const clearIcon = screen.getByTestId('compact-filter-clear');
await userEvent.click(clearIcon);
expect(onClear).toHaveBeenCalledTimes(1);
});
test('does not render tooltip wrapper when tooltipTitle is absent', () => {
const { container } = renderTrigger();
expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
});
test('shows active state indicators when hasValue and tooltipTitle are set', () => {
renderTrigger({ hasValue: true, tooltipTitle: 'Some Owner' });
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('calls children render prop with isOpen and onClose', async () => {
const children = jest.fn(() => <div data-testid="panel-content">panel</div>);
renderTrigger({}, children);
const pill = screen.getByTestId('compact-filter-pill');
await userEvent.click(pill);
expect(children).toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
);
});
test('sets aria-haspopup to listbox by default', () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
});
test('sets aria-haspopup to dialog when popupType is dialog', () => {
renderTrigger({ popupType: 'dialog' });
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
});
test('closing dropdown resets aria-expanded to false', async () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'false');
});

View File

@@ -0,0 +1,198 @@
/**
* 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 {
useEffect,
useRef,
useState,
type ReactNode,
type MouseEvent,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
export type FilterPanelRenderProps = {
isOpen: boolean;
onClose: () => void;
};
interface CompactFilterTriggerProps {
label: ReactNode;
hasValue: boolean;
onClear: () => void;
/** Render prop: receives { isOpen, onClose } and returns the panel content. */
children: (props: FilterPanelRenderProps) => ReactNode;
/** Shown as a hover tooltip when a value is selected (e.g. the selected label). */
tooltipTitle?: string;
/** ARIA popup role for the trigger button. Use 'listbox' for option panels,
* 'dialog' for form panels (date range, numerical range). */
popupType?: 'listbox' | 'dialog';
}
const FilterPill = styled.button<{ $active: boolean }>`
${({ theme, $active }) => css`
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit}px;
height: ${theme.controlHeight}px;
padding: 0 ${theme.sizeUnit * 3}px;
border-radius: ${theme.borderRadius}px;
border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
color: ${$active ? theme.colorPrimary : theme.colorText};
font-size: ${theme.fontSizeSM}px;
font-weight: ${$active ? 600 : 400};
cursor: pointer;
white-space: nowrap;
line-height: 1;
transition:
border-color 0.2s,
background 0.2s,
color 0.2s;
/* AntD anticon spans carry vertical-align: -0.125em from global styles.
align-self centers the span within the pill; the inner flex+align-items
centers the svg within the span. */
.anticon {
display: flex;
align-items: center;
align-self: center;
line-height: 0;
}
&:hover {
border-color: ${theme.colorPrimary};
background: ${$active ? theme.colorPrimaryBgHover : theme.colorFillAlter};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimary};
outline-offset: 2px;
}
`}
`;
const ActiveDot = styled.span`
${({ theme }) => css`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${theme.colorPrimary};
flex-shrink: 0;
`}
`;
export default function CompactFilterTrigger({
label,
hasValue,
onClear,
children,
tooltipTitle,
popupType = 'listbox',
}: CompactFilterTriggerProps) {
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const theme = useTheme();
// Tracks whether tooltip should be suppressed after dropdown close.
// Brave (and some other browsers) fire a synthetic mouseover on newly-exposed
// elements when a popup disappears, triggering Tooltip onOpenChange(true)
// without real user intent. We suppress until the cursor actually leaves the
// pill (onMouseLeave), which is the first reliable "hover reset" signal.
const tooltipSuppressedRef = useRef(false);
// Close dropdown on window resize — AntD Dropdown doesn't reposition
// itself on resize so the panel ends up detached from the pill.
useEffect(() => {
if (!open) return;
const handleResize = () => setOpen(false);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [open]);
const handleClear = (e: MouseEvent) => {
e.stopPropagation();
onClear();
setOpen(false);
tooltipSuppressedRef.current = true;
setTooltipOpen(false);
};
return (
<Dropdown
open={open}
onOpenChange={visible => {
setOpen(visible);
if (!visible) {
tooltipSuppressedRef.current = true;
setTooltipOpen(false);
}
}}
trigger={['click']}
popupRender={() =>
children({ isOpen: open, onClose: () => setOpen(false) })
}
placement="bottomLeft"
destroyPopupOnHide
>
<Tooltip
title={tooltipTitle}
open={!!tooltipTitle && !open && tooltipOpen}
onOpenChange={visible => {
if (visible && tooltipSuppressedRef.current) return;
setTooltipOpen(visible && !!tooltipTitle && !open);
}}
mouseEnterDelay={0.5}
mouseLeaveDelay={0}
>
<FilterPill
$active={hasValue}
type="button"
data-test="compact-filter-pill"
aria-haspopup={popupType}
aria-expanded={open}
aria-label={typeof label === 'string' ? label : undefined}
onMouseLeave={() => {
tooltipSuppressedRef.current = false;
}}
>
{hasValue && <ActiveDot />}
<span>{label}</span>
{hasValue ? (
<Icons.CloseOutlined
iconSize="s"
iconColor={theme.colorPrimary}
onClick={handleClear}
data-test="compact-filter-clear"
aria-label={
typeof label === 'string'
? t('Clear %s filter', label)
: undefined
}
/>
) : (
<Icons.DownOutlined
iconSize="s"
iconColor={theme.colorTextSecondary}
/>
)}
</FilterPill>
</Tooltip>
</Dropdown>
);
}

View File

@@ -0,0 +1,339 @@
/**
* 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 { createRef, act } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import CompactSelectPanel from './CompactSelectPanel';
import type { FilterHandler } from './types';
const SMALL_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
];
const LARGE_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
{ label: 'David', value: 4 },
{ label: 'Eve', value: 5 },
{ label: 'Frank', value: 6 },
{ label: 'Grace', value: 7 },
];
beforeEach(() => {
jest.clearAllMocks();
});
test('renders options from selects prop', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
});
test('hides search input when selects.length is 6 or fewer', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
});
test('shows search input when selects.length exceeds 6', () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('shows search input when fetchSelects is provided', () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('filters static options by search term', async () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
});
test('calls onSelect with normalized option when an option is clicked', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('calls onSelect with undefined when same option is clicked twice (deselect)', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith(undefined, true);
});
test('shows checkmark icon on selected option', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const aliceOption = screen
.getByText('Alice')
.closest('[role="option"]') as HTMLElement;
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
});
test('unselected options have aria-selected false', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const bobOption = screen
.getByText('Bob')
.closest('[role="option"]') as HTMLElement;
expect(bobOption).toHaveAttribute('aria-selected', 'false');
});
test('calls onClose after a selection is made', async () => {
const onClose = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('clearFilter via ref resets selection and calls onSelect(undefined, true)', () => {
const onSelect = jest.fn();
const ref = createRef<FilterHandler>();
const { rerender } = render(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
act(() => {
ref.current?.clearFilter();
});
expect(onSelect).toHaveBeenCalledWith(undefined, true);
// Component is fully controlled — visual deselection follows when the
// parent passes value={undefined} after receiving the onSelect callback.
rerender(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('shows Loading text when loading prop is true', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
loading
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows No results when displayOptions is empty', () => {
render(
<CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
);
expect(screen.getByText('No results')).toBeInTheDocument();
});
test('renders options list with listbox role and accessible label', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const listbox = screen.getByRole('listbox');
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute('aria-label', 'Filter options');
});
test('option items have option role', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
test('fetches and displays remote options via fetchSelects on mount', async () => {
const fetchSelects = jest.fn().mockResolvedValue({
data: [{ label: 'Remote User', value: 99 }],
totalCount: 1,
});
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Remote User')).toBeInTheDocument();
});
expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
});
test('shows No results when fetchSelects returns empty data', async () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('shows No results when fetchSelects rejects', async () => {
const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('selects option via keyboard Enter key', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
await userEvent.type(aliceOption, '{Enter}');
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('syncs selected state when external value prop changes', () => {
const { rerender } = render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
rerender(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});

View File

@@ -0,0 +1,318 @@
/**
* 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 {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useState,
useEffect,
type CSSProperties,
type RefObject,
} from 'react';
import { debounce } from 'lodash';
import { t } from '@apache-superset/core/translation';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import {
Icons,
Input,
Constants,
type InputRef,
} from '@superset-ui/core/components';
import type { SelectOption, ListViewFilter as Filter } from '../types';
import type { FilterHandler } from './types';
// Show search box when there are more than this many static options.
const SEARCH_THRESHOLD = 6;
// Page size for async select fetches — large enough to avoid most pagination
// issues while still being a bounded request. Full infinite-load pagination
// is a future improvement.
const ASYNC_PAGE_SIZE = 200;
interface CompactSelectPanelProps {
selects?: Filter['selects'];
fetchSelects?: Filter['fetchSelects'];
value?: SelectOption;
onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
onClose?: () => void;
isOpen?: boolean;
/** Forwarded from the filter config's popupStyle for per-filter width overrides */
panelStyle?: CSSProperties;
/** External loading state from filter config */
loading?: boolean;
}
const PanelContainer = styled.div`
${({ theme }) => css`
min-width: 220px;
max-width: 320px;
max-height: 320px;
display: flex;
flex-direction: column;
border-radius: ${theme.borderRadiusLG}px;
background: ${theme.colorBgElevated};
box-shadow: ${theme.boxShadowSecondary};
padding: 0 0 ${theme.paddingXXS}px;
`}
`;
const SearchRow = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px
${theme.paddingXXS}px;
`}
`;
const OptionList = styled.ul`
${({ theme }) => css`
margin: 0;
padding: ${theme.paddingXXS}px 0;
overflow-y: auto;
flex: 1;
list-style: none;
`}
`;
const OptionItem = styled.li<{ $active: boolean }>`
${({ theme, $active }) => css`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${(theme.controlHeight - theme.fontSize * theme.lineHeight) / 2}px
${theme.controlPaddingHorizontal}px;
line-height: ${theme.lineHeight};
cursor: pointer;
font-size: ${theme.fontSize}px;
color: ${theme.colorText};
border-radius: ${theme.borderRadiusSM}px;
background: ${$active ? theme.colorPrimaryBg : 'transparent'};
transition: background 0.15s;
&:hover {
background: ${$active
? theme.colorPrimaryBgHover
: theme.colorFillTertiary};
outline: 2px solid ${theme.colorPrimary};
outline-offset: -2px;
}
`}
`;
const OptionLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
`;
const StatusText = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
text-align: center;
color: ${theme.colorTextDisabled};
font-size: ${theme.fontSizeSM}px;
`}
`;
function CompactSelectPanel(
{
selects = [],
fetchSelects,
value,
onSelect,
onClose,
isOpen,
loading: externalLoading,
panelStyle,
}: CompactSelectPanelProps,
ref: RefObject<FilterHandler>,
) {
const theme = useTheme();
const inputRef = useRef<InputRef>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [remoteOptions, setRemoteOptions] = useState<SelectOption[]>([]);
const [internalLoading, setInternalLoading] = useState(false);
const isLoading = externalLoading || internalLoading;
const debouncedSetSearch = useMemo(
() => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
[],
);
useEffect(
() => () => {
debouncedSetSearch.cancel();
},
[debouncedSetSearch],
);
// Focus search input when dropdown opens; reset search when it closes
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (isOpen) {
timeoutId = setTimeout(() => {
inputRef.current?.input?.focus({ preventScroll: true });
}, 100);
} else {
setSearch('');
setDebouncedSearch('');
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [isOpen]);
// Fetch remote options when debounced search changes
useEffect(() => {
if (!fetchSelects) return;
let cancelled = false;
setInternalLoading(true);
fetchSelects(debouncedSearch, 0, ASYNC_PAGE_SIZE)
.then(result => {
if (!cancelled) setRemoteOptions(result?.data ?? []);
})
.catch(() => {
if (!cancelled) setRemoteOptions([]);
})
.finally(() => {
if (!cancelled) setInternalLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedSearch, fetchSelects]);
useImperativeHandle(ref, () => ({
clearFilter: () => {
setSearch('');
setDebouncedSearch('');
onSelect(undefined, true);
},
}));
const displayOptions = (
fetchSelects
? remoteOptions
: selects.filter(o => {
const label = typeof o.label === 'string' ? o.label : String(o.value);
return label.toLowerCase().includes(search.toLowerCase());
})
).filter(o => o != null);
const showSearch = !!fetchSelects || selects.length > SEARCH_THRESHOLD;
const handleSelect = (opt: SelectOption, displayText?: string) => {
const isDeselect = value?.value === opt.value;
// Normalize to a plain string label for URL serialization:
// 1. String labels pass through unchanged.
// 2. ReactNode labels with a `title` field use that (set by callers for
// options like owner-select where label contains name + email JSX).
// 3. Fall back to DOM text content, then stringified value.
const label =
typeof opt.label === 'string'
? opt.label
: (opt.title ?? displayText ?? String(opt.value ?? ''));
const next = isDeselect ? undefined : { label, value: opt.value };
onSelect(next, isDeselect);
onClose?.();
};
return (
<PanelContainer style={panelStyle}>
{showSearch && (
<SearchRow>
<Input
ref={inputRef}
prefix={
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
}
placeholder={t('Search')}
value={search}
onChange={e => {
setSearch(e.target.value);
debouncedSetSearch(e.target.value);
}}
allowClear
css={css`
width: 100%;
box-shadow: none;
`}
/>
</SearchRow>
)}
<OptionList role="listbox" aria-label={t('Filter options')}>
{isLoading ? (
<StatusText>{t('Loading...')}</StatusText>
) : displayOptions.length === 0 ? (
<StatusText>{t('No results')}</StatusText>
) : (
displayOptions.map((opt, i) => {
const isActive = value?.value === opt.value;
const getDisplayText = (el: HTMLElement) =>
el.textContent?.trim() || undefined;
const isFirst = i === 0;
const isLast = i === displayOptions.length - 1;
return (
<OptionItem
key={opt.value}
$active={isActive}
role="option"
aria-selected={isActive}
tabIndex={0}
onClick={e =>
handleSelect(opt, getDisplayText(e.currentTarget))
}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(opt, getDisplayText(e.currentTarget));
} else if (e.key === 'ArrowDown' && !isLast) {
e.preventDefault();
(
e.currentTarget.nextElementSibling as HTMLElement | null
)?.focus();
} else if (e.key === 'ArrowUp' && !isFirst) {
e.preventDefault();
(
e.currentTarget
.previousElementSibling as HTMLElement | null
)?.focus();
}
}}
>
<OptionLabel>{opt.label}</OptionLabel>
{isActive && (
<Icons.CheckOutlined
iconSize="s"
iconColor={theme.colorPrimary}
/>
)}
</OptionItem>
);
})
)}
</OptionList>
</PanelContainer>
);
}
export default forwardRef(CompactSelectPanel);

View File

@@ -1,112 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
useState,
useMemo,
forwardRef,
useImperativeHandle,
RefObject,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { Dayjs } from 'dayjs';
import { useLocale } from 'src/hooks/useLocale';
import { extendedDayjs } from '@superset-ui/core/utils/dates';
import {
AntdThemeProvider,
Loading,
FormLabel,
RangePicker,
} from '@superset-ui/core/components';
import type { BaseFilter, FilterHandler } from './types';
import { FilterContainer } from './Base';
import { RANGE_WIDTH } from '../utils';
interface DateRangeFilterProps extends BaseFilter {
onSubmit: (val: number[] | string[]) => void;
name: string;
dateFilterValueType?: 'unix' | 'iso';
}
type ValueState = [number, number] | [string, string] | null;
function DateRangeFilter(
{
Header,
initialValue,
onSubmit,
dateFilterValueType = 'unix',
}: DateRangeFilterProps,
ref: RefObject<FilterHandler>,
) {
const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
const dayjsValue = useMemo((): [Dayjs, Dayjs] | null => {
if (!value || (Array.isArray(value) && !value.length)) return null;
return [extendedDayjs(value[0]), extendedDayjs(value[1])];
}, [value]);
const locale = useLocale();
useImperativeHandle(ref, () => ({
clearFilter: () => {
setValue(null);
onSubmit([]);
},
}));
if (locale === null) {
return <Loading position="inline-centered" />;
}
return (
<AntdThemeProvider locale={locale}>
<FilterContainer
data-test="date-range-filter-container"
vertical
justify="center"
align="start"
width={RANGE_WIDTH}
>
<FormLabel>{Header}</FormLabel>
<RangePicker
placeholder={[t('Start date'), t('End date')]}
showTime
value={dayjsValue}
onCalendarChange={(dayjsRange: [Dayjs, Dayjs]) => {
if (!dayjsRange?.[0]?.valueOf() || !dayjsRange?.[1]?.valueOf()) {
setValue(null);
onSubmit([]);
return;
}
const changeValue =
dateFilterValueType === 'iso'
? [dayjsRange[0].toISOString(), dayjsRange[1].toISOString()]
: [
dayjsRange[0]?.valueOf() ?? 0,
dayjsRange[1]?.valueOf() ?? 0,
];
setValue(changeValue as ValueState);
onSubmit(changeValue);
}}
/>
</FilterContainer>
</AntdThemeProvider>
);
}
export default forwardRef(DateRangeFilter);

View File

@@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import FilterPopoverContent from './FilterPopoverContent';
beforeEach(() => {
jest.clearAllMocks();
});
test('renders children inside the wrapper', () => {
render(
<FilterPopoverContent>
<div data-test="inner-content">Inner content</div>
</FilterPopoverContent>,
);
expect(screen.getByTestId('inner-content')).toBeInTheDocument();
});
test('renders the Apply button', () => {
render(
<FilterPopoverContent>
<div>content</div>
</FilterPopoverContent>,
);
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('calls onClose when Apply button is clicked', async () => {
const onClose = jest.fn();
render(
<FilterPopoverContent onClose={onClose}>
<div>content</div>
</FilterPopoverContent>,
);
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('renders without onClose and clicking Apply does not throw', async () => {
render(
<FilterPopoverContent>
<div>content</div>
</FilterPopoverContent>,
);
// No onClose prop — click should not throw
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('visually hides label elements so pills remain accessible', () => {
render(
<FilterPopoverContent>
<label htmlFor="input">Date range</label>
<input id="input" />
</FilterPopoverContent>,
);
const label = screen.getByText('Date range');
// The label must be in the DOM for screen readers but visually hidden via CSS
expect(label).toBeInTheDocument();
const computedStyle = window.getComputedStyle(label);
// clip / overflow hidden pattern applied; position absolute is the key indicator
expect(computedStyle.position).toBe('absolute');
});

View File

@@ -0,0 +1,74 @@
/**
* 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 type { ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, css } from '@apache-superset/core/theme';
import { Button } from '@superset-ui/core/components';
interface FilterPopoverContentProps {
children: ReactNode;
onClose?: () => void;
}
const Wrapper = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
background: ${theme.colorBgElevated};
border-radius: ${theme.borderRadiusLG}px;
box-shadow: ${theme.boxShadowSecondary};
/* Visually hide the redundant label — the pill already shows it, but keep it
accessible to screen readers so filter inputs have a named context. */
label {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`}
`;
const Footer = styled.div`
display: flex;
justify-content: flex-end;
`;
export default function FilterPopoverContent({
children,
onClose,
}: FilterPopoverContentProps) {
return (
<Wrapper>
{children}
<Footer>
<Button size="small" buttonStyle="primary" onClick={onClose}>
{t('Apply')}
</Button>
</Footer>
</Wrapper>
);
}

View File

@@ -1,267 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createRef } from 'react';
import {
render,
screen,
selectOption,
waitFor,
} from 'spec/helpers/testing-library';
import { ListViewFilterOperator } from '../types';
import UIFilters from './index';
import SelectFilter from './Select';
import type { FilterHandler } from './types';
const mockUpdateFilterValue = jest.fn();
beforeEach(() => {
mockUpdateFilterValue.mockClear();
});
test('select filter with ReactNode label uses option title when serializing selection', async () => {
// Regression for sc-104554: the chart-list Owner filter renders options
// with ReactNode labels (name + email). The value passed to
// updateFilterValue is serialized into URL / filter state and re-used to
// render the filter pill on return. It must carry the plain-text name
// (from `title`) and not fall back to the numeric user id.
const ReactNodeLabel = (
<div>
<span>John Doe</span>
<span>john@example.com</span>
</div>
);
const fetchSelects = jest.fn().mockResolvedValue({
data: [
{
label: ReactNodeLabel,
value: 42,
title: 'John Doe',
},
],
totalCount: 1,
});
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
fetchSelects,
paginate: true,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('John Doe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'John Doe',
value: 42,
});
});
});
test('select filter falls back to stringified value when no string label or title is available', async () => {
const fetchSelects = jest.fn().mockResolvedValue({
data: [
{
label: <span>123</span>,
value: 123,
},
],
totalCount: 1,
});
const filters = [
{
Header: 'Something',
key: 'something',
id: 'something',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
unfilteredLabel: 'All',
fetchSelects,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('123', 'Something');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: '123',
value: 123,
});
});
});
test('plain select with string label passes label through unchanged', async () => {
// Happy-path coverage for the typeof-string branch in onChange, exercised
// through the non-async Select wrapper (selects array, no fetchSelects).
const filters = [
{
Header: 'Status',
key: 'status',
id: 'status',
input: 'select' as const,
operator: ListViewFilterOperator.Equals,
unfilteredLabel: 'All',
selects: [
{ label: 'Published', value: 7 },
{ label: 'Draft', value: 8 },
],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('Published', 'Status');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'Published',
value: 7,
});
});
});
test('plain select with ReactNode label uses option title when serializing selection', async () => {
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
// the non-async Select wrapper. Guards against the two wrappers ever
// diverging on antd's two-arg onChange shape.
const ReactNodeLabel = (
<div>
<span>Jane Roe</span>
<span>jane@example.com</span>
</div>
);
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('Jane Roe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'Jane Roe',
value: 99,
});
});
});
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
// The isClear flag is what allows the parent (Filters/index) to suppress
// onFilterUpdate side-effects when the user clears the filter rather than
// picking a new value. Lock that contract in.
const mockOnSelect = jest.fn();
const ref = createRef<FilterHandler>();
render(
<SelectFilter
Header="Owner"
initialValue={{ label: 'John Doe', value: 42 }}
onSelect={mockOnSelect}
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
ref={ref}
/>,
);
ref.current?.clearFilter();
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
});
test('rehydrates filter pill from initialValue with plain-string label', async () => {
// The user-visible regression: after URL/state rehydration the filter pill
// must render the human-readable name, not the numeric user id. The fix
// ensures the persisted label is a string; this test asserts that string
// is what surfaces in the rendered combobox selection.
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
paginate: true,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owners',
operator: ListViewFilterOperator.RelationManyMany,
value: { label: 'John Doe', value: 42 },
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});

View File

@@ -1,154 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
useState,
useMemo,
forwardRef,
useImperativeHandle,
type RefObject,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { Select, AsyncSelect, FormLabel } from '@superset-ui/core/components';
import { ListViewFilter as Filter, SelectOption } from '../types';
import type { BaseFilter, FilterHandler } from './types';
import { FilterContainer } from './Base';
import { SELECT_WIDTH } from '../utils';
interface SelectFilterProps extends BaseFilter {
fetchSelects?: Filter['fetchSelects'];
name?: string;
onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void;
optionFilterProps?: string[];
paginate?: boolean;
selects: Filter['selects'];
loading?: boolean;
dropdownStyle?: React.CSSProperties;
}
function SelectFilter(
{
Header,
name,
fetchSelects,
initialValue,
onSelect,
optionFilterProps,
selects = [],
loading = false,
dropdownStyle,
}: SelectFilterProps,
ref: RefObject<FilterHandler>,
) {
const [selectedOption, setSelectedOption] = useState(initialValue);
const onChange = (selected: SelectOption, option?: SelectOption) => {
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
// labeled-value as the first arg and the full option (which carries
// `title` and any other fields) as the second. Options may supply a
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
// filter). Since this object is serialized into the URL and rehydrated
// as the filter pill on return, we need a plain string. Prefer `title`
// (set by callers to the human-readable name) before falling back to
// the value.
onSelect(
selected
? {
label:
typeof selected.label === 'string'
? selected.label
: (option?.title ?? String(selected.value)),
value: selected.value,
}
: undefined,
);
setSelectedOption(selected);
};
const onClear = () => {
onSelect(undefined, true);
setSelectedOption(undefined);
};
useImperativeHandle(ref, () => ({
clearFilter: () => {
onClear();
},
}));
const fetchAndFormatSelects = useMemo(
() => async (inputValue: string, page: number, pageSize: number) => {
if (fetchSelects) {
const selectValues = await fetchSelects(inputValue, page, pageSize);
return {
data: selectValues.data,
totalCount: selectValues.totalCount,
};
}
return {
data: [],
totalCount: 0,
};
},
[fetchSelects],
);
const placeholder = t('Choose...');
return (
<FilterContainer
data-test="select-filter-container"
width={SELECT_WIDTH}
vertical
justify="center"
align="start"
>
<FormLabel>{Header}</FormLabel>
{fetchSelects ? (
<AsyncSelect
allowClear
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
data-test="filters-select"
onChange={onChange}
onClear={onClear}
options={fetchAndFormatSelects}
optionFilterProps={optionFilterProps}
placeholder={placeholder}
dropdownStyle={dropdownStyle}
showSearch
value={selectedOption}
/>
) : (
<Select
allowClear
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
data-test="filters-select"
labelInValue
onChange={onChange}
onClear={onClear}
options={selects}
placeholder={placeholder}
dropdownStyle={dropdownStyle}
showSearch
value={selectedOption}
loading={loading}
/>
)}
</FilterContainer>
);
}
export default forwardRef(SelectFilter);

View File

@@ -0,0 +1,251 @@
/**
* 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 { createRef, act } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { NO_TIME_RANGE, SupersetClient } from '@superset-ui/core';
import TimeRangeFilter from './TimeRange';
import type { FilterHandler } from './types';
// Suppress debounced evaluation — the initial useEffect handles the committed
// value; the debounced path is an optimistic UX enhancement, not a contract.
jest.mock('src/explore/exploreUtils', () => ({
...jest.requireActual('src/explore/exploreUtils'),
useDebouncedEffect: jest.fn(),
}));
jest.mock('src/explore/components/controls/DateFilterControl/utils', () => ({
FRAME_OPTIONS: [
{ label: 'No filter', value: 'No filter' },
{ label: 'Custom', value: 'Custom' },
],
guessFrame: jest.fn().mockReturnValue('Custom'),
// 'No filter' is the string value of NO_TIME_RANGE constant
useDefaultTimeFilter: jest.fn().mockReturnValue('No filter'),
}));
jest.mock(
'src/explore/components/controls/DateFilterControl/components',
() => ({
AdvancedFrame: () => <div data-test="advanced-frame" />,
CalendarFrame: () => <div data-test="calendar-frame" />,
CommonFrame: () => <div data-test="common-frame" />,
CustomFrame: ({ value }: { value: string }) => (
<div data-test="custom-frame">{value}</div>
),
}),
);
jest.mock(
'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame',
() => ({
CurrentCalendarFrame: () => <div data-testid="current-calendar-frame" />,
}),
);
const VALID_RANGE = '2024-01-01 : 2024-01-31';
// Default successful response that fetchTimeRange and the Apply handler both use
const MOCK_TIME_RANGE_RESULT = {
json: {
result: [{ since: '2024-01-01T00:00:00', until: '2024-01-31T23:59:59' }],
},
};
let getSpy: jest.SpyInstance;
beforeEach(() => {
getSpy = jest
.spyOn(SupersetClient, 'get')
.mockResolvedValue(MOCK_TIME_RANGE_RESULT as any);
});
afterEach(() => {
getSpy.mockRestore();
});
function renderFilter(
props: Partial<{
value: string;
onSubmit: jest.Mock;
onClose: jest.Mock;
}> = {},
) {
const onSubmit = props.onSubmit ?? jest.fn();
const onClose = props.onClose ?? jest.fn();
return render(
<TimeRangeFilter
value={props.value ?? VALID_RANGE}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
}
test('renders range type label, actual time range section, and footer buttons', () => {
renderFilter();
expect(screen.getByText('Range type')).toBeInTheDocument();
expect(screen.getByText('Actual time range')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('shows the custom frame when guessFrame returns Custom', () => {
renderFilter();
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
});
test('Apply is disabled until the API validates the initial value', async () => {
// Block resolution so we can observe disabled state
let resolve: (v: typeof MOCK_TIME_RANGE_RESULT) => void;
getSpy.mockReturnValue(
new Promise(res => {
resolve = res;
}),
);
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
expect(apply).toBeDisabled();
act(() => {
resolve!(MOCK_TIME_RANGE_RESULT);
});
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
});
test('Apply is enabled when the API returns a valid result', async () => {
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
});
test('Apply is disabled when the API returns an error response', async () => {
getSpy.mockRejectedValue(new Error('Bad request'));
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
// Give fetchTimeRange time to reject and set validTimeRange=false
await waitFor(() => {
expect(apply).toBeDisabled();
});
});
test('Cancel button calls onClose', async () => {
const onClose = jest.fn();
renderFilter({ onClose });
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('Apply calls onSubmit([since, until]) and onClose when API succeeds', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
renderFilter({ onSubmit, onClose });
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
await userEvent.click(apply);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith([
'2024-01-01T00:00:00',
'2024-01-31T23:59:59',
]);
});
expect(onClose).toHaveBeenCalledTimes(1);
});
test('Apply calls onClose but not onSubmit when the API call throws', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
// fetchTimeRange succeeds (for validTimeRange), but the Apply API call fails
getSpy
.mockResolvedValueOnce(MOCK_TIME_RANGE_RESULT as any) // fetchTimeRange in useEffect
.mockRejectedValueOnce(new Error('network')); // Apply button API call
renderFilter({ onSubmit, onClose });
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
await userEvent.click(apply);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(onSubmit).not.toHaveBeenCalled();
});
test('Apply with NO_TIME_RANGE calls onSubmit(undefined) and onClose without an API call', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
render(
<TimeRangeFilter
value={NO_TIME_RANGE}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
const callsBefore = getSpy.mock.calls.length;
await userEvent.click(apply);
expect(onSubmit).toHaveBeenCalledWith(undefined);
expect(onClose).toHaveBeenCalledTimes(1);
// No extra API call for NO_TIME_RANGE — the button short-circuits
expect(getSpy.mock.calls.length).toBe(callsBefore);
});
test('clearFilter via ref calls onSubmit(undefined)', async () => {
const onSubmit = jest.fn();
const ref = createRef<FilterHandler>();
render(
<TimeRangeFilter
ref={ref}
value={VALID_RANGE}
onSubmit={onSubmit}
onClose={jest.fn()}
/>,
);
act(() => {
ref.current?.clearFilter();
});
expect(onSubmit).toHaveBeenCalledWith(undefined);
});

View File

@@ -0,0 +1,291 @@
/**
* 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 {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
type RefObject,
} from 'react';
import { t } from '@apache-superset/core/translation';
import {
NO_TIME_RANGE,
SupersetClient,
fetchTimeRange,
} from '@superset-ui/core';
import rison from 'rison';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import {
Button,
Constants,
Divider,
Icons,
Select,
} from '@superset-ui/core/components';
import { useDebouncedEffect } from 'src/explore/exploreUtils';
import {
FRAME_OPTIONS,
guessFrame,
useDefaultTimeFilter,
} from 'src/explore/components/controls/DateFilterControl/utils';
import {
AdvancedFrame,
CalendarFrame,
CommonFrame,
CustomFrame,
} from 'src/explore/components/controls/DateFilterControl/components';
import { CurrentCalendarFrame } from 'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame';
import type { FrameType } from 'src/explore/components/controls/DateFilterControl/types';
import type { FilterHandler } from './types';
interface TimeRangeFilterProps {
value?: string;
onSubmit: (value: [string, string] | undefined) => void;
onClose: () => void;
}
const StyledRangeType = styled(Select)`
width: 272px;
`;
const ContentWrapper = styled.div`
${({ theme }) => css`
width: 600px;
padding: ${theme.sizeUnit * 3}px;
background: ${theme.colorBgElevated};
border-radius: ${theme.borderRadiusLG}px;
box-shadow: ${theme.boxShadowSecondary};
.ant-row {
margin-top: 8px;
}
.ant-picker {
padding: 4px 17px 4px;
border-radius: 4px;
}
.ant-divider-horizontal {
margin: 16px 0;
}
.control-label {
font-size: ${theme.fontSizeSM}px;
line-height: 16px;
margin: 8px 0;
}
.section-title {
font-style: normal;
font-weight: ${theme.fontWeightStrong};
font-size: 15px;
line-height: 24px;
margin-bottom: 8px;
}
.control-anchor-to {
margin-top: 16px;
}
.control-anchor-to-datetime {
width: 217px;
}
.footer {
text-align: right;
}
`}
`;
const IconWrapper = styled.span`
span {
margin-right: ${({ theme }) => 2 * theme.sizeUnit}px;
vertical-align: middle;
}
.text {
vertical-align: middle;
}
.error {
color: ${({ theme }) => theme.colorError};
}
`;
function TimeRangeFilter(
{ value: valueProp, onSubmit, onClose }: TimeRangeFilterProps,
ref: RefObject<FilterHandler>,
) {
const defaultTimeFilter = useDefaultTimeFilter();
const value = valueProp ?? defaultTimeFilter;
const theme = useTheme();
// guessedFrame is only used for the initial useState — value is stable at
// mount because CompactFilterTrigger uses destroyPopupOnHide, so the panel
// always mounts fresh with the current committed value.
const guessedFrame = useMemo(() => guessFrame(value), [value]);
const [frame, setFrame] = useState<FrameType>(guessedFrame);
const [timeRangeValue, setTimeRangeValue] = useState(value);
const [evalResponse, setEvalResponse] = useState(value);
const [validTimeRange, setValidTimeRange] = useState(false);
const [lastFetched, setLastFetched] = useState(value);
// Evaluate the committed value shown in "Actual time range".
useEffect(() => {
if (value === NO_TIME_RANGE) {
setEvalResponse(NO_TIME_RANGE);
setValidTimeRange(true);
return;
}
fetchTimeRange(value).then(({ value: actual, error }) => {
if (error) {
setEvalResponse(error ?? '');
setValidTimeRange(false);
} else {
setEvalResponse(actual ?? value);
setValidTimeRange(true);
}
setLastFetched(value);
});
}, [value]);
// Debounced evaluation of the in-progress selection (drives "Actual time range").
useDebouncedEffect(
() => {
if (timeRangeValue === NO_TIME_RANGE) {
setEvalResponse(NO_TIME_RANGE);
setLastFetched(NO_TIME_RANGE);
setValidTimeRange(true);
return;
}
if (lastFetched !== timeRangeValue) {
fetchTimeRange(timeRangeValue).then(({ value: actual, error }) => {
if (error) {
setEvalResponse(error ?? '');
setValidTimeRange(false);
} else {
setEvalResponse(actual ?? '');
setValidTimeRange(true);
}
setLastFetched(timeRangeValue);
});
}
},
Constants.SLOW_DEBOUNCE,
[timeRangeValue],
);
useImperativeHandle(ref, () => ({
clearFilter: () => {
onSubmit(undefined);
},
}));
function onChangeFrame(val: FrameType) {
if (val === NO_TIME_RANGE) {
setTimeRangeValue(NO_TIME_RANGE);
}
setFrame(val);
}
return (
<ContentWrapper>
<div className="control-label">{t('Range type')}</div>
<StyledRangeType
ariaLabel={t('Range type')}
options={FRAME_OPTIONS}
value={frame}
onChange={onChangeFrame}
/>
{frame !== 'No filter' && <Divider />}
{frame === 'Common' && (
<CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Calendar' && (
<CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Current' && (
<CurrentCalendarFrame
value={timeRangeValue}
onChange={setTimeRangeValue}
/>
)}
{frame === 'Advanced' && (
<AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Custom' && (
<CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
<Divider />
<div>
<div className="section-title">{t('Actual time range')}</div>
{validTimeRange && (
<div>
{evalResponse === NO_TIME_RANGE ? t('No filter') : evalResponse}
</div>
)}
{!validTimeRange && (
<IconWrapper className="warning">
<Icons.ExclamationCircleOutlined iconColor={theme.colorError} />
<span className="text error">{evalResponse}</span>
</IconWrapper>
)}
</div>
<Divider />
<div className="footer">
<Button buttonStyle="secondary" cta key="cancel" onClick={onClose}>
{t('CANCEL')}
</Button>
<Button
buttonStyle="primary"
cta
disabled={!validTimeRange}
key="apply"
onClick={async () => {
if (timeRangeValue === NO_TIME_RANGE) {
onSubmit(undefined);
onClose();
return;
}
// fetchTimeRange returns a formatted display string ("X ≤ col < Y"),
// not the raw since/until strings. Call the API directly to get them.
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/time_range/?q=${rison.encode_uri(timeRangeValue)}`,
});
const since: string | undefined =
response?.json?.result[0]?.since;
const until: string | undefined =
response?.json?.result[0]?.until;
if (since !== undefined && until !== undefined) {
onSubmit([since, until]);
}
} catch {
// leave filter unchanged on error
}
onClose();
}}
>
{t('APPLY')}
</Button>
</div>
</ContentWrapper>
);
}
export default forwardRef(TimeRangeFilter);

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { ListViewFilterOperator } from '../types';
import UIFilters from './index';
@@ -97,7 +98,335 @@ test('search filter passes autoComplete prop correctly', () => {
expect(input.autocomplete).toBe('new-password');
});
test('renders multiple search filters with different inputName values', () => {
test('renders a compact pill trigger for select filters', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(screen.getByTestId('compact-filter-pill')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
});
test('select pill shows active state (clear button) when a value is selected', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [{ label: 'Alice', value: 1 }],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owner',
operator: ListViewFilterOperator.RelationOneMany,
value: { label: 'Alice', value: 1 },
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
});
test('select pill tooltip falls back to static selects on cold URL load (no cached label)', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
],
},
];
// Simulate cold URL load: value has only numeric value, no label in cache
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owner',
operator: ListViewFilterOperator.RelationOneMany,
value: { value: 1 } as any,
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// The pill should be active (clear button visible) and the static label
// should be resolved as the tooltip source
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
});
test('datetime_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
expect(screen.getByText('Time range')).toBeInTheDocument();
});
test('datetime_range pill shows active state when a time range string is set', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'Last week',
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// Clear icon is inside the pill (not a separate button)
const pill = screen.getByTestId('compact-filter-pill');
const clearIcon = screen.getByTestId('compact-filter-clear');
expect(clearIcon).toBeInTheDocument();
expect(pill).toContainElement(clearIcon);
});
test('datetime_range pill is inactive when value is NO_TIME_RANGE', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'No filter',
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
});
test('datetime_range pill shows the time range string as tooltip title', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'Last month',
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// Pill is active and clear icon is inside
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
});
test('numerical_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
expect(screen.getByText('Age range')).toBeInTheDocument();
});
test('numerical_range pill shows active state when value is set', () => {
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'age_range',
operator: ListViewFilterOperator.Between,
value: [18, 65],
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(
screen.getByRole('button', { name: /clear age range filter/i }),
).toBeInTheDocument();
});
test('datetime_range onClear calls updateFilterValue with undefined directly', async () => {
const updateFilterValue = jest.fn();
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'Last week',
},
]}
updateFilterValue={updateFilterValue}
/>,
);
const clearIcon = screen.getByTestId('compact-filter-clear');
await userEvent.click(clearIcon);
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
});
test('numerical_range onClear calls updateFilterValue with undefined directly', async () => {
const updateFilterValue = jest.fn();
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'age_range',
operator: ListViewFilterOperator.Between,
value: [18, 65],
},
]}
updateFilterValue={updateFilterValue}
/>,
);
const clearBtn = screen.getByRole('button', {
name: /clear age range filter/i,
});
await userEvent.click(clearBtn);
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
});
test('renders only the first search filter when multiple search filters are configured', () => {
const filters = [
{
Header: 'Name',
@@ -125,8 +454,8 @@ test('renders multiple search filters with different inputName values', () => {
/>,
);
// Only the first search filter renders — one search box per page
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
expect(inputs).toHaveLength(2);
expect(inputs).toHaveLength(1);
expect(inputs[0].name).toBe('filter_name_search');
expect(inputs[1].name).toBe('description');
});

View File

@@ -19,12 +19,16 @@
import {
createRef,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
RefObject,
} from 'react';
import { withTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import type {
ListViewFilterValue as FilterValue,
@@ -33,10 +37,13 @@ import type {
SelectOption,
} from '../types';
import type { FilterHandler } from './types';
import { NO_TIME_RANGE } from '@superset-ui/core';
import SearchFilter from './Search';
import SelectFilter from './Select';
import DateRangeFilter from './DateRange';
import NumericalRangeFilter from './NumericalRange';
import TimeRangeFilter from './TimeRange';
import CompactFilterTrigger from './CompactFilterTrigger';
import CompactSelectPanel from './CompactSelectPanel';
import FilterPopoverContent from './FilterPopoverContent';
interface UIFiltersProps {
filters: Filters;
@@ -46,7 +53,10 @@ interface UIFiltersProps {
function UIFilters(
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
ref: RefObject<{ clearFilters: () => void }>,
ref: RefObject<{
clearFilters: () => void;
clearFilterById: (id: string) => void;
}>,
) {
const filterRefs = useMemo(
() =>
@@ -54,125 +64,320 @@ function UIFilters(
[filters.length],
);
// Cache display labels for select filters so tooltip works after URL round-trip
// (URL serialization strips the label, leaving only the value).
const [tooltipLabels, setTooltipLabels] = useState<Record<number, string>>(
{},
);
// Evaluated human-readable labels for datetime_range pills (e.g. "2024-05-01 : 2024-05-31").
const [timeRangeTooltips, setTimeRangeTooltips] = useState<
Record<number, string>
>({});
// On cold load, URL params restore values but not labels for fetchSelects filters.
// Fetch the first page of options and cache the matching label so the tooltip works.
useEffect(() => {
filters.forEach((filter, index) => {
if (filter.input !== 'select' || !filter.fetchSelects) return;
if (tooltipLabels[index]) return;
const val = internalFilters?.[index]?.value as SelectOption | undefined;
if (!val?.value) return;
filter.fetchSelects('', 0, 500).then(result => {
const match = result?.data?.find(
(s: SelectOption) => s.value === val.value,
);
if (match) {
const lbl =
typeof match.label === 'string'
? match.label
: String(match.value ?? '');
setTooltipLabels(prev => ({ ...prev, [index]: lbl }));
}
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [internalFilters]);
// Build datetime_range tooltips from the resolved [start, end] array value.
// Handles both ISO strings and unix-ms numbers.
useEffect(() => {
filters.forEach((filter, index) => {
if (filter.input !== 'datetime_range') return;
const val = internalFilters?.[index]?.value;
if (Array.isArray(val) && val.length === 2) {
const fmt = (v: unknown) => {
const d = new Date(v as string | number);
return isNaN(d.getTime())
? String(v)
: d.toISOString().replace('T', ' ').slice(0, 19);
};
const tooltip = `${fmt(val[0])} ${fmt(val[1])}`;
setTimeRangeTooltips(prev =>
prev[index] === tooltip ? prev : { ...prev, [index]: tooltip },
);
} else {
setTimeRangeTooltips(prev => {
if (!(index in prev)) return prev;
const next = { ...prev };
delete next[index];
return next;
});
}
});
}, [filters, internalFilters]);
const clearFilterAtIndex = useCallback(
(index: number) => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
setTooltipLabels(prev => {
const next = { ...prev };
delete next[index];
return next;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[updateFilterValue],
);
useImperativeHandle(ref, () => ({
clearFilters: () => {
filterRefs.forEach((filter: any) => {
filter.current?.clearFilter?.();
filterRefs.forEach((_, index) => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
});
setTooltipLabels({});
setTimeRangeTooltips({});
},
clearFilterById: (id: string) => {
const index = filters.findIndex(f => f.id === id);
if (index >= 0) {
filterRefs[index]?.current?.clearFilter?.();
clearFilterAtIndex(index);
}
},
}));
return (
<>
{filters.map(
(
{
Header,
fetchSelects,
key,
id,
input,
optionFilterProps,
paginate,
selects,
toolTipDescription,
onFilterUpdate,
loading,
dateFilterValueType,
min,
max,
popupStyle,
autoComplete,
inputName,
},
index,
) => {
const initialValue = internalFilters?.[index]?.value;
if (input === 'select') {
return (
<SelectFilter
// Search always leads the filter bar regardless of declaration order.
// Only the first search filter renders; subsequent ones are skipped (see note below).
// NOTE: This means secondary search fields (e.g. Email/Username on Users,
// Group Key on RLS) are not currently accessible via the filter bar. Those
// pages previously relied on multiple inline inputs. This is a known UX
// trade-off — revisit if admin workflows require additional search fields.
let searchFilterRendered = false;
// Render in two passes: search first, then all other filter types.
const renderFilter = (_: (typeof filters)[number], index: number) => {
const {
Header,
fetchSelects,
key,
id,
input,
selects,
toolTipDescription,
onFilterUpdate,
loading,
min,
max,
autoComplete,
inputName,
popupStyle,
dateFilterValueType,
} = filters[index];
const initialValue = internalFilters?.[index]?.value;
if (input === 'select') {
const selectValue = initialValue as SelectOption | undefined;
// Prefer cached label (survives URL round-trips where only the value
// is preserved). Fall back to the static selects list for cold loads.
const cachedLabel = tooltipLabels[index];
const staticFallback = cachedLabel
? undefined
: selects?.find(s => s.value === selectValue?.value)?.label;
const tooltipTitle = !!selectValue
? cachedLabel ||
(typeof staticFallback === 'string' ? staticFallback : undefined)
: t('Choose...');
return (
<span key={key} data-test="select-filter-container">
<CompactFilterTrigger
label={Header}
hasValue={!!selectValue}
tooltipTitle={tooltipTitle}
onClear={() => clearFilterAtIndex(index)}
>
{({ isOpen, onClose }) => (
<CompactSelectPanel
ref={filterRefs[index]}
Header={Header}
selects={selects}
fetchSelects={fetchSelects}
initialValue={initialValue}
key={key}
name={id}
value={initialValue as SelectOption | undefined}
loading={loading ?? false}
isOpen={isOpen}
onClose={onClose}
panelStyle={popupStyle}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
if (onFilterUpdate) {
// Filter change triggers both onChange AND onClear, only want to track onChange
if (!isClear) {
onFilterUpdate(option);
}
if (option && !isClear) {
setTooltipLabels(prev => ({
...prev,
[index]:
typeof option.label === 'string'
? option.label
: String(option.value ?? ''),
}));
}
if (onFilterUpdate && !isClear) {
onFilterUpdate(option);
}
updateFilterValue(index, option);
}}
optionFilterProps={optionFilterProps}
paginate={paginate}
selects={selects}
loading={loading ?? false}
dropdownStyle={popupStyle}
/>
);
}
if (input === 'search' && typeof Header === 'string') {
return (
<SearchFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
key={key}
name={inputName ?? id}
toolTipDescription={toolTipDescription}
onSubmit={(value: string) => {
if (onFilterUpdate) {
onFilterUpdate(value);
}
)}
</CompactFilterTrigger>
</span>
);
}
if (input === 'search' && typeof Header === 'string') {
if (searchFilterRendered) return null;
searchFilterRendered = true;
return (
<SearchFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
key={key}
name={inputName ?? id}
toolTipDescription={toolTipDescription}
onSubmit={(value: string) => {
if (onFilterUpdate) {
onFilterUpdate(value);
}
updateFilterValue(index, value);
}}
autoComplete={autoComplete}
/>
);
}
if (input === 'datetime_range') {
// dateFilterValueType absent or 'unix': column stores unix ms (e.g. Query History start_time).
// 'iso': column stores ISO date strings (e.g. UsersList created_on, ActionLog dttm).
const isUnixType = !dateFilterValueType || dateFilterValueType === 'unix';
// initialValue may be [ms, ms] (unix), ["iso","iso"] (iso), or legacy string.
// Always reconstruct panelValue as "ISO : ISO" so the TimeRange panel
// can parse it as a Custom date range regardless of storage type.
let resolvedIsoRange: [string, string] | null = null;
if (Array.isArray(initialValue) && initialValue.length === 2) {
if (typeof initialValue[0] === 'number') {
resolvedIsoRange = [
new Date(initialValue[0]).toISOString(),
new Date(initialValue[1] as number).toISOString(),
];
} else if (typeof initialValue[0] === 'string') {
resolvedIsoRange = initialValue as [string, string];
}
}
const legacyStringVal =
!resolvedIsoRange &&
typeof initialValue === 'string' &&
initialValue !== NO_TIME_RANGE
? initialValue
: null;
const hasTimeValue = !!(resolvedIsoRange || legacyStringVal);
const panelValue =
resolvedIsoRange?.join(' : ') ?? legacyStringVal ?? undefined;
return (
<CompactFilterTrigger
key={key}
label={Header}
hasValue={hasTimeValue}
tooltipTitle={
hasTimeValue ? (timeRangeTooltips[index] ?? panelValue) : undefined
}
popupType="dialog"
onClear={() => {
updateFilterValue(index, undefined);
}}
>
{({ onClose }) => (
<TimeRangeFilter
ref={filterRefs[index]}
value={panelValue}
onClose={onClose}
onSubmit={value => {
if (!value) {
updateFilterValue(index, undefined);
} else if (isUnixType) {
// Convert ISO strings to unix ms for numeric columns
updateFilterValue(index, [
new Date(value[0]).getTime(),
new Date(value[1]).getTime(),
]);
} else {
updateFilterValue(index, value);
}}
autoComplete={autoComplete}
/>
);
}
if (input === 'datetime_range') {
return (
<DateRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
dateFilterValueType={dateFilterValueType || 'unix'}
/>
);
}
if (input === 'numerical_range') {
return (
}
}}
/>
)}
</CompactFilterTrigger>
);
}
if (input === 'numerical_range') {
const hasRangeValue =
Array.isArray(initialValue) &&
initialValue.some(v => v !== null && v !== undefined);
const rangeTooltip = hasRangeValue
? (initialValue as (number | null | undefined)[])
.filter(v => v !== null && v !== undefined)
.join(' ')
: undefined;
return (
<CompactFilterTrigger
key={key}
label={Header}
hasValue={hasRangeValue}
tooltipTitle={rangeTooltip}
popupType="dialog"
onClear={() => {
updateFilterValue(index, undefined);
}}
>
{({ onClose }) => (
<FilterPopoverContent onClose={onClose}>
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
);
}
return null;
},
</FilterPopoverContent>
)}
</CompactFilterTrigger>
);
}
return null;
};
return (
<>
{/* Search first */}
{filters.map((_, index) =>
filters[index].input === 'search'
? renderFilter(filters[index], index)
: null,
)}
{/* Then all other filter types */}
{filters.map((_, index) =>
filters[index].input !== 'search'
? renderFilter(filters[index], index)
: null,
)}
</>
);

View File

@@ -301,15 +301,19 @@ describe('ListView', () => {
});
test('renders UI filters', () => {
const filterControls = screen.getAllByRole('combobox');
expect(filterControls).toHaveLength(2);
// select and datetime_range filters render as compact pill buttons;
// search filter renders as a text input
const filterPills = screen.getAllByTestId('compact-filter-pill');
expect(filterPills).toHaveLength(3); // ID, Age, Time
});
test('calls fetchData on filter', async () => {
// Handle select filter
const selectFilter = screen.getAllByRole('combobox')[0];
await userEvent.click(selectFilter);
const option = screen.getByText('foo');
// Click the ID compact pill to open its option panel
const idPill = screen.getByRole('button', { name: 'ID' });
await userEvent.click(idPill);
// Wait for and click the 'foo' option in the dropdown panel
const option = await screen.findByRole('option', { name: 'foo' });
await userEvent.click(option);
// Handle search filter
@@ -341,7 +345,10 @@ describe('ListView', () => {
initialSort: [{ id: 'something' }],
});
const sortSelect = screen.getByTestId('card-sort-select');
const sortSelectContainer = screen.getByTestId('card-sort-select');
const sortSelect = sortSelectContainer.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement;
await userEvent.click(sortSelect);
const sortOption = screen.getByText('Alphabetical');

View File

@@ -65,13 +65,43 @@ const ListViewStyles = styled.div`
.header {
display: flex;
align-items: center;
padding-bottom: ${theme.sizeUnit * 4}px;
& .controls {
display: flex;
flex-wrap: wrap;
column-gap: ${theme.sizeUnit * 7}px;
row-gap: ${theme.sizeUnit * 4}px;
align-items: center;
column-gap: ${theme.sizeUnit * 2}px;
row-gap: ${theme.sizeUnit * 2}px;
/* Search input — fixed width/height matching pill height, label hidden */
[data-test='search-filter-container'] {
width: ${theme.sizeUnit * 44}px;
flex-shrink: 0;
height: ${theme.controlHeight}px;
align-self: center;
/* Hide the FormLabel Flex wrapper entirely so it doesn't affect
the column's justify-content centering calculation. */
> .ant-flex {
display: none;
}
label {
display: none;
}
.ant-input-affix-wrapper {
width: 100%;
height: 100%;
}
}
/* Select filter pill wrappers — make them proper flex items so the
inline-flex button inside doesn't introduce line-box quirks. */
[data-test='select-filter-container'] {
display: flex;
align-items: center;
align-self: center;
}
}
}
@@ -167,7 +197,6 @@ const bulkSelectColumnConfig = {
const ViewModeContainer = styled.div`
${({ theme }) => `
padding-right: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 5 + 1}px;
white-space: nowrap;
display: inline-block;
@@ -192,6 +221,29 @@ const ViewModeContainer = styled.div`
`}
`;
const ClearAllButton = styled.button`
${({ theme }) => `
background: none;
border: none;
padding: 0 ${theme.sizeUnit}px;
color: ${theme.colorPrimary};
font-size: ${theme.fontSizeSM}px;
cursor: pointer;
white-space: nowrap;
line-height: ${theme.controlHeight}px;
&:hover:not(:disabled) {
color: ${theme.colorPrimaryHover};
text-decoration: underline;
}
&:disabled {
color: ${theme.colorTextDisabled};
cursor: not-allowed;
}
`}
`;
const EmptyWrapper = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 40}px 0;
@@ -356,6 +408,14 @@ export function ListView<T extends object = any>({
clearFilterById: (id: string) => void;
}>(null);
const hasActiveFilters = internalFilters.some(f => {
if (f.value === null || f.value === undefined || f.value === '')
return false;
if (Array.isArray(f.value))
return f.value.some(v => v !== null && v !== undefined && v !== '');
return true;
});
// Wire the optional external filtersRef to our internal filterControlsRef.
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
// ref is populated before the first paint and after every update.
@@ -421,6 +481,21 @@ export function ListView<T extends object = any>({
options={cardSortSelectOptions}
/>
)}
{filterable && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
>
<span>
<ClearAllButton
type="button"
disabled={!hasActiveFilters}
onClick={() => filterControlsRef.current?.clearFilters()}
>
{t('Clear all')}
</ClearAllButton>
</span>
</Tooltip>
)}
</div>
</div>
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>

View File

@@ -57,9 +57,12 @@ const mockUser = {
const findFilterByLabel = (labelText: string) => {
const containers = screen.getAllByTestId('select-filter-container');
for (const container of containers) {
const label = container.querySelector('label');
if (label?.textContent === labelText) {
return container.querySelector('[role="combobox"], .ant-select');
// Compact pill filters show the label as button text
const pill = container.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement | null;
if (pill && pill.textContent?.includes(labelText)) {
return pill;
}
}
return null;

View File

@@ -156,18 +156,16 @@ describe('DashboardList Card View Tests', () => {
).toBeInTheDocument();
});
// Find the sort select by its testId, then the combobox within it
// Find the sort select by its testId, then the pill button within it
const sortContainer = screen.getByTestId('card-sort-select');
const sortCombobox = within(sortContainer).getByRole('combobox');
await userEvent.click(sortCombobox);
// eslint-disable-next-line testing-library/no-node-access
const sortPill = sortContainer.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement;
await userEvent.click(sortPill);
// Select "Alphabetical" from the dropdown
const alphabeticalOption = await waitFor(() =>
within(
// eslint-disable-next-line testing-library/no-node-access
document.querySelector('.rc-virtual-list')!,
).getByText('Alphabetical'),
);
const alphabeticalOption = await screen.findByText('Alphabetical');
await userEvent.click(alphabeticalOption);
await waitFor(() => {

View File

@@ -20,7 +20,7 @@ import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import {
screen,
selectOption,
selectPillOption,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
@@ -200,7 +200,7 @@ test('selecting Status filter encodes published=true in API call', async () => {
).toBeInTheDocument();
});
await selectOption('Published', 'Status');
await selectPillOption('Published', 'Status');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
@@ -242,7 +242,7 @@ test('selecting Owner filter encodes rel_m_m owner in API call', async () => {
).toBeInTheDocument();
});
await selectOption('Admin User', 'Owner');
await selectPillOption('Admin User', 'Owner');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
@@ -287,7 +287,7 @@ test('selecting Modified by filter encodes rel_o_m changed_by in API call', asyn
).toBeInTheDocument();
});
await selectOption('Admin User', 'Modified by');
await selectPillOption('Admin User', 'Modified by');
await waitFor(() => {
const latest = getLatestDashboardApiCall();

View File

@@ -20,7 +20,7 @@ import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { selectOption } from 'spec/helpers/testing-library';
import { selectPillOption } from 'spec/helpers/testing-library';
import {
setupMocks,
renderDatasetList,
@@ -102,11 +102,11 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
).toBeGreaterThan(callsBeforeSort);
});
// 2. Apply a filter using selectOption helper
// 2. Apply a filter using selectPillOption helper (compact pill UI)
const beforeFilterCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
await selectOption('Virtual', 'Type');
await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {

View File

@@ -27,7 +27,7 @@ import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { SupersetClient } from '@superset-ui/core';
import { selectOption } from 'spec/helpers/testing-library';
import { selectPillOption } from 'spec/helpers/testing-library';
import {
setupMocks,
renderDatasetList,
@@ -1510,11 +1510,8 @@ test('bulk selection clears when filter changes', async () => {
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Wait for filter combobox to be ready before applying filter
await screen.findByRole('combobox', { name: 'Type' });
// Apply a filter using selectOption helper
await selectOption('Virtual', 'Type');
// Apply a filter using selectPillOption helper (compact pill UI)
await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1556,16 +1553,13 @@ test('type filter API call includes correct filter parameter', async () => {
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
});
// Wait for Type filter combobox
await screen.findByRole('combobox', { name: 'Type' });
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply Type filter
await selectOption('Virtual', 'Type');
// Apply Type filter using compact pill UI
await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1606,16 +1600,13 @@ test('type filter persists after duplicating a dataset', async () => {
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
});
// Wait for Type filter combobox
await screen.findByRole('combobox', { name: 'Type' });
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply Type filter
await selectOption('Virtual', 'Type');
// Apply Type filter using compact pill UI
await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {

View File

@@ -200,8 +200,8 @@ test('renders Name search filter', async () => {
test('renders Type filter (Virtual/Physical dropdown)', async () => {
renderDatasetList(mockAdminUser);
// Filter dropdowns should be present
const filters = await screen.findAllByRole('combobox');
// Filter pills should be present (compact pill UI)
const filters = await screen.findAllByTestId('compact-filter-pill');
expect(filters.length).toBeGreaterThan(0);
});
@@ -445,7 +445,8 @@ test('selecting Database filter triggers API call with database relation filter'
await waitForDatasetsPageReady();
const filtersContainers = screen.getAllByRole('combobox');
// Filter pills should be present (compact pill UI replaces comboboxes)
const filtersContainers = screen.getAllByTestId('compact-filter-pill');
expect(filtersContainers.length).toBeGreaterThan(0);
});

View File

@@ -121,13 +121,17 @@ describe('GroupsList', () => {
test('renders the filters correctly', async () => {
await renderComponent();
const filtersSelect = screen.getAllByTestId('filters-select')[0];
expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
// The compact filter UI renders the first search filter as an input,
// and select filters as pill buttons. Only "Name" search renders inline;
// "Label" and "Description" searches are hidden (one search box per page).
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
// Select filters render as compact pill buttons
const pills = screen.getAllByTestId('compact-filter-pill');
const pillLabels = pills.map(p => p.textContent ?? '');
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
expect(pillLabels.some(l => /users/i.test(l))).toBe(true);
});
test('renders correct columns in the table', async () => {

View File

@@ -151,8 +151,11 @@ describe('RolesList', () => {
test('renders filters options', async () => {
await renderAndWait();
const typeFilter = screen.queryAllByTestId('filters-select');
expect(typeFilter).toHaveLength(4);
// Compact filter UI: one search input for "Name" and 3 select pills
// (Users, Permissions, Groups).
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
const selectContainers = screen.getAllByTestId('select-filter-container');
expect(selectContainers).toHaveLength(3);
});
test('renders correct list columns', async () => {

View File

@@ -166,11 +166,14 @@ describe('RuleList RTL', () => {
test('renders filter options', async () => {
await renderAndWait();
// Compact filter UI: only the first search filter renders (Name),
// subsequent search filters (Group Key) are hidden — one search box per page.
const searchFilters = screen.queryAllByTestId('filters-search');
expect(searchFilters).toHaveLength(2);
expect(searchFilters).toHaveLength(1);
const typeFilter = screen.queryAllByTestId('filters-select');
expect(typeFilter).toHaveLength(3); // Update to expect 3 select filters
// Select filters render as compact pill buttons (Filter Type, Modified by)
const selectContainers = screen.queryAllByTestId('select-filter-container');
expect(selectContainers).toHaveLength(2);
});
test('renders correct list columns', async () => {

View File

@@ -138,16 +138,16 @@ describe('UsersList', () => {
test('renders filters options', async () => {
await renderAndWait();
const submenu = screen.queryAllByTestId('filters-select')[0];
expect(within(submenu).getByText(/first name/i)).toBeInTheDocument();
expect(within(submenu).getByText(/last name/i)).toBeInTheDocument();
expect(within(submenu).getByText(/email/i)).toBeInTheDocument();
expect(within(submenu).getByText(/username/i)).toBeInTheDocument();
expect(within(submenu).getByText(/roles/i)).toBeInTheDocument();
expect(within(submenu).getByText(/is active?/i)).toBeInTheDocument();
expect(within(submenu).getByText(/created on/i)).toBeInTheDocument();
expect(within(submenu).getByText(/changed on/i)).toBeInTheDocument();
expect(within(submenu).getByText(/last login/i)).toBeInTheDocument();
// The compact filter UI shows: only the first search filter as an input,
// and select/datetime filters as pill buttons. Only "First name" search
// renders (subsequent search filters are hidden — one search box per page).
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
// Select and datetime filters render as compact pill buttons
const pills = screen.getAllByTestId('compact-filter-pill');
const pillLabels = pills.map(p => p.textContent ?? '');
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
expect(pillLabels.some(l => /is active\?/i.test(l))).toBe(true);
});
test('renders correct list columns', async () => {

View File

@@ -159,6 +159,14 @@ User and Role Management:
- list_roles: List roles with filtering (1-based pagination, admin only)
- get_role_info: Get role details by ID (admin only)
Row Level Security (Admin only):
- list_rls_filters: List RLS filters with filtering and search (1-based pagination)
- get_rls_filter_info: Get detailed RLS filter info by ID (tables, roles, clause)
Plugins (Admin only):
- list_plugins: List dynamic plugins with filtering and search (1-based pagination)
- get_plugin_info: Get detailed plugin info by ID (name, key, bundle URL)
Dataset Management:
- list_datasets: List datasets with advanced filters (1-based pagination)
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
@@ -401,9 +409,10 @@ IMPORTANT - Tool-Only Interaction:
General usage tips:
- All listing tools use 1-based pagination (first page is 1)
- Use get_schema to discover filterable columns, sortable columns, and default columns
for chart/dataset/dashboard/database. For action_log and task tools, consult each
tool's docstring — filterable and sortable columns are listed there directly.
- Use get_schema (chart/dataset/dashboard/database) to discover filterable columns,
sortable columns, and default columns for those resource types
- For action_log, task, list_rls_filters, and list_plugins tools, filterable/sortable
columns are listed inline in each tool's docstring — get_schema does not cover these
- Use 'filters' parameter for advanced queries with filter columns from get_schema
- IDs can be integer or UUID format where supported
- All tools return structured, Pydantic-typed responses
@@ -708,10 +717,18 @@ from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
from superset.mcp_service.explore.tool import ( # noqa: F401, E402
generate_explore_link,
)
from superset.mcp_service.plugin.tool import ( # noqa: F401, E402
get_plugin_info,
list_plugins,
)
from superset.mcp_service.query.tool import ( # noqa: F401, E402
get_query_info,
list_queries,
)
from superset.mcp_service.rls.tool import ( # noqa: F401, E402
get_rls_filter_info,
list_rls_filters,
)
from superset.mcp_service.role.tool import ( # noqa: F401, E402
get_role_info,
list_roles,

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,23 @@
# 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 superset.daos.base import BaseDAO
from superset.models.dynamic_plugins import DynamicPlugin
class DynamicPluginDAO(BaseDAO[DynamicPlugin]):
pass

View File

@@ -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.
"""
Pydantic schemas for dynamic plugin responses.
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
DEFAULT_PLUGIN_COLUMNS = ["id", "name", "key", "bundle_url"]
ALL_PLUGIN_COLUMNS = [
"id",
"name",
"key",
"bundle_url",
"changed_on",
"created_on",
]
SORTABLE_PLUGIN_COLUMNS = ["id", "name", "key", "changed_on", "created_on"]
class PluginColumnFilter(ColumnOperator):
"""Filter object for plugin listing."""
col: Literal["name", "key"] = Field(..., description="Column to filter on.")
opr: ColumnOperatorEnum = Field(..., description="Operator to use.")
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by"
)
class PluginInfo(BaseModel):
id: int | None = Field(None, description="Plugin ID")
name: str | None = Field(None, description="Plugin display name")
key: str | None = Field(None, description="Plugin key (corresponds to viz_type)")
bundle_url: str | None = Field(None, description="URL to the plugin bundle")
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
created_on: str | datetime | None = Field(None, description="Creation timestamp")
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
data = serializer(self)
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class PluginList(BaseModel):
plugins: List[PluginInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(default_factory=list)
columns_loaded: List[str] = Field(default_factory=list)
columns_available: List[str] = Field(default_factory=list)
sortable_columns: List[str] = Field(default_factory=list)
filters_applied: List[PluginColumnFilter] = Field(default_factory=list)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListPluginsRequest(BaseModel):
"""Request schema for list_plugins."""
filters: Annotated[
List[PluginColumnFilter],
Field(
default_factory=list,
description="List of filter objects (col, opr, value). "
"Cannot be used with search.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="Columns to include in response. Defaults to common columns.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search on plugin name or key. "
"Cannot be used with filters.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(default="desc", description="Sort direction"),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[PluginColumnFilter]:
return parse_json_or_model_list(v, PluginColumnFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListPluginsRequest":
if self.search and self.filters:
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
return self
class PluginError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@classmethod
def create(cls, error: str, error_type: str) -> "PluginError":
from datetime import timezone
return cls(
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
)
class GetPluginInfoRequest(BaseModel):
"""Request schema for get_plugin_info."""
identifier: Annotated[
int,
Field(description="Plugin ID"),
]
def serialize_plugin_object(plugin: Any) -> PluginInfo | None:
if not plugin:
return None
return PluginInfo(
id=getattr(plugin, "id", None),
name=getattr(plugin, "name", None),
key=getattr(plugin, "key", None),
bundle_url=getattr(plugin, "bundle_url", None),
changed_on=getattr(plugin, "changed_on", None),
created_on=getattr(plugin, "created_on", None),
)

View File

@@ -0,0 +1,24 @@
# 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 .get_plugin_info import get_plugin_info
from .list_plugins import list_plugins
__all__ = [
"list_plugins",
"get_plugin_info",
]

View File

@@ -0,0 +1,101 @@
# 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.
"""
Get plugin info FastMCP tool.
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelGetInfoCore
from superset.mcp_service.plugin.schemas import (
GetPluginInfoRequest,
PluginError,
PluginInfo,
serialize_plugin_object,
)
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="DynamicPlugin",
annotations=ToolAnnotations(
title="Get plugin info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_plugin_info(
request: GetPluginInfoRequest, ctx: Context
) -> PluginInfo | PluginError:
"""Get dynamic plugin details by ID. Requires admin access.
Returns full plugin configuration including name, key, and bundle URL.
Example usage:
```json
{"identifier": 1}
```
"""
await ctx.info(
"Retrieving plugin information: identifier=%s" % (request.identifier,)
)
try:
from superset.mcp_service.plugin.dao import DynamicPluginDAO
with event_logger.log_context(action="mcp.get_plugin_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=DynamicPluginDAO,
output_schema=PluginInfo,
error_schema=PluginError,
serializer=serialize_plugin_object,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, PluginInfo):
await ctx.info(
"Plugin retrieved: id=%s, name=%s, key=%s"
% (result.id, result.name, result.key)
)
else:
await ctx.warning(
"Plugin retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Plugin info retrieval failed: identifier=%s, error=%s"
% (request.identifier, str(e))
)
return PluginError(
error=f"Failed to get plugin info: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,123 @@
# 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.
"""
List plugins FastMCP tool.
"""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelListCore
from superset.mcp_service.plugin.schemas import (
ALL_PLUGIN_COLUMNS,
DEFAULT_PLUGIN_COLUMNS,
ListPluginsRequest,
PluginColumnFilter,
PluginError,
PluginInfo,
PluginList,
serialize_plugin_object,
SORTABLE_PLUGIN_COLUMNS,
)
logger = logging.getLogger(__name__)
_DEFAULT_LIST_PLUGINS_REQUEST = ListPluginsRequest()
@tool(
tags=["core"],
class_permission_name="DynamicPlugin",
annotations=ToolAnnotations(
title="List plugins",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_plugins(
request: ListPluginsRequest | None = None,
ctx: Context | None = None,
) -> PluginList | PluginError:
"""List dynamic plugins registered in this Superset instance. Requires admin access.
Returns plugin metadata including name, key, and bundle URL.
Sortable columns for order_column: id, name, key, changed_on, created_on
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_plugins")
request = request or _DEFAULT_LIST_PLUGINS_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing plugins: page=%s, page_size=%s, search=%s"
% (request.page, request.page_size, request.search)
)
try:
from superset.mcp_service.plugin.dao import DynamicPluginDAO
def _serialize_plugin(obj: object, cols: list[str]) -> PluginInfo | None:
return serialize_plugin_object(obj)
list_tool = ModelListCore(
dao_class=DynamicPluginDAO,
output_schema=PluginInfo,
item_serializer=_serialize_plugin,
filter_type=PluginColumnFilter,
default_columns=DEFAULT_PLUGIN_COLUMNS,
search_columns=["name", "key"],
list_field_name="plugins",
output_list_schema=PluginList,
all_columns=ALL_PLUGIN_COLUMNS,
sortable_columns=SORTABLE_PLUGIN_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_plugins.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"Plugins listed: count=%s, total_count=%s"
% (len(result.plugins), result.total_count)
)
columns_to_filter = result.columns_requested
with event_logger.log_context(action="mcp.list_plugins.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"Plugin listing failed: error=%s, error_type=%s"
% (str(e), type(e).__name__)
)
raise

View File

@@ -140,7 +140,7 @@ def user_can_view_data_model_metadata() -> bool:
def filter_user_directory_fields(data: dict[str, Any]) -> dict[str, Any]:
"""Remove fields that expose users, roles, owners, or access metadata."""
"""Remove fields that expose users, owners, or access metadata."""
return {
key: value for key, value in data.items() if key not in USER_DIRECTORY_FIELDS
}

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,255 @@
# 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.
"""
Pydantic schemas for row level security filter responses.
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
DEFAULT_RLS_COLUMNS = ["id", "name", "filter_type", "clause"]
ALL_RLS_COLUMNS = [
"id",
"name",
"filter_type",
"tables",
"roles",
"clause",
"group_key",
"changed_on",
]
SORTABLE_RLS_COLUMNS = ["id", "name", "filter_type", "changed_on"]
class RlsColumnFilter(ColumnOperator):
"""Filter object for RLS filter listing."""
col: Literal["name", "filter_type"] = Field(
...,
description="Column to filter on.",
)
opr: ColumnOperatorEnum = Field(..., description="Operator to use.")
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by"
)
class RlsTableRef(BaseModel):
id: int | None = Field(None, description="Table ID")
table_name: str | None = Field(None, description="Table name")
model_config = ConfigDict(from_attributes=True)
class RlsRoleRef(BaseModel):
id: int | None = Field(None, description="Role ID")
name: str | None = Field(None, description="Role name")
model_config = ConfigDict(from_attributes=True)
class RlsFilterInfo(BaseModel):
id: int | None = Field(None, description="RLS filter ID")
name: str | None = Field(None, description="RLS filter name")
filter_type: str | None = Field(None, description="Filter type: Regular or Base")
tables: List[RlsTableRef] | None = Field(
None, description="Tables this filter applies to"
)
roles: List[RlsRoleRef] | None = Field(
None, description="Roles this filter applies to"
)
clause: str | None = Field(None, description="SQL WHERE clause")
group_key: str | None = Field(
None, description="Group key for Base filter grouping"
)
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
data = serializer(self)
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class RlsFilterList(BaseModel):
rls_filters: List[RlsFilterInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(default_factory=list)
columns_loaded: List[str] = Field(default_factory=list)
columns_available: List[str] = Field(default_factory=list)
sortable_columns: List[str] = Field(default_factory=list)
filters_applied: List[RlsColumnFilter] = Field(default_factory=list)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListRlsFiltersRequest(BaseModel):
"""Request schema for list_rls_filters."""
filters: Annotated[
List[RlsColumnFilter],
Field(
default_factory=list,
description="List of filter objects (col, opr, value). "
"Cannot be used with search.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="Columns to include in response. Defaults to common columns.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search on filter name. Cannot be used with filters.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(default="desc", description="Sort direction"),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[RlsColumnFilter]:
return parse_json_or_model_list(v, RlsColumnFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListRlsFiltersRequest":
if self.search and self.filters:
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
return self
class RlsFilterError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@classmethod
def create(cls, error: str, error_type: str) -> "RlsFilterError":
from datetime import timezone
return cls(
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
)
class GetRlsFilterInfoRequest(BaseModel):
"""Request schema for get_rls_filter_info."""
identifier: Annotated[
int,
Field(description="RLS filter ID"),
]
def serialize_rls_filter_object(rls_filter: Any) -> RlsFilterInfo | None:
if not rls_filter:
return None
tables = [
RlsTableRef(
id=getattr(t, "id", None),
table_name=getattr(t, "table_name", None),
)
for t in (getattr(rls_filter, "tables", None) or [])
]
roles = [
RlsRoleRef(
id=getattr(r, "id", None),
name=getattr(r, "name", None),
)
for r in (getattr(rls_filter, "roles", None) or [])
]
return RlsFilterInfo(
id=getattr(rls_filter, "id", None),
name=getattr(rls_filter, "name", None),
filter_type=getattr(rls_filter, "filter_type", None),
tables=tables,
roles=roles,
clause=getattr(rls_filter, "clause", None),
group_key=getattr(rls_filter, "group_key", None),
changed_on=getattr(rls_filter, "changed_on", None),
)

View File

@@ -0,0 +1,24 @@
# 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 .get_rls_filter_info import get_rls_filter_info
from .list_rls_filters import list_rls_filters
__all__ = [
"list_rls_filters",
"get_rls_filter_info",
]

View File

@@ -0,0 +1,101 @@
# 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.
"""
Get RLS filter info FastMCP tool.
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelGetInfoCore
from superset.mcp_service.rls.schemas import (
GetRlsFilterInfoRequest,
RlsFilterError,
RlsFilterInfo,
serialize_rls_filter_object,
)
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Row Level Security",
annotations=ToolAnnotations(
title="Get RLS filter info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_rls_filter_info(
request: GetRlsFilterInfoRequest, ctx: Context
) -> RlsFilterInfo | RlsFilterError:
"""Get row level security filter details by ID. Requires admin access.
Returns full RLS filter configuration including name, type, tables, roles,
and clause.
Example usage:
```json
{"identifier": 1}
```
"""
await ctx.info(
"Retrieving RLS filter information: identifier=%s" % (request.identifier,)
)
try:
from superset.daos.security import RLSDAO
with event_logger.log_context(action="mcp.get_rls_filter_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=RLSDAO,
output_schema=RlsFilterInfo,
error_schema=RlsFilterError,
serializer=serialize_rls_filter_object,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, RlsFilterInfo):
await ctx.info(
"RLS filter retrieved: id=%s, name=%s" % (result.id, result.name)
)
else:
await ctx.warning(
"RLS filter retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"RLS filter info retrieval failed: identifier=%s, error=%s"
% (request.identifier, str(e))
)
return RlsFilterError(
error=f"Failed to get RLS filter info: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,147 @@
# 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.
"""
List RLS filters FastMCP tool.
"""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelListCore
from superset.mcp_service.privacy import USER_DIRECTORY_FIELDS
from superset.mcp_service.rls.schemas import (
ALL_RLS_COLUMNS,
DEFAULT_RLS_COLUMNS,
ListRlsFiltersRequest,
RlsColumnFilter,
RlsFilterError,
RlsFilterInfo,
RlsFilterList,
serialize_rls_filter_object,
SORTABLE_RLS_COLUMNS,
)
logger = logging.getLogger(__name__)
_DEFAULT_LIST_RLS_FILTERS_REQUEST = ListRlsFiltersRequest()
@tool(
tags=["core"],
class_permission_name="Row Level Security",
annotations=ToolAnnotations(
title="List RLS filters",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_rls_filters(
request: ListRlsFiltersRequest | None = None,
ctx: Context | None = None,
) -> RlsFilterList | RlsFilterError:
"""List row level security filters. Requires admin access.
Returns RLS filter metadata including name, filter type, tables, roles, and clause.
Sortable columns for order_column: id, name, filter_type, changed_on
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_rls_filters")
request = request or _DEFAULT_LIST_RLS_FILTERS_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing RLS filters: page=%s, page_size=%s, search=%s"
% (request.page, request.page_size, request.search)
)
try:
from superset.daos.security import RLSDAO
def _serialize_rls_filter(obj: object, cols: list[str]) -> RlsFilterInfo | None:
return serialize_rls_filter_object(obj)
list_tool = ModelListCore(
dao_class=RLSDAO,
output_schema=RlsFilterInfo,
item_serializer=_serialize_rls_filter,
filter_type=RlsColumnFilter,
default_columns=DEFAULT_RLS_COLUMNS,
search_columns=["name"],
list_field_name="rls_filters",
output_list_schema=RlsFilterList,
all_columns=ALL_RLS_COLUMNS,
sortable_columns=SORTABLE_RLS_COLUMNS,
logger=logger,
)
# Strip USER_DIRECTORY_FIELDS (e.g. 'roles') before handing off to
# run_tool, which would raise ValueError if all requested columns are
# privacy-filtered. Roles are restored in the model_dump context below.
run_select_columns: list[str] | None = None
if request.select_columns:
filtered = [
c for c in request.select_columns if c not in USER_DIRECTORY_FIELDS
]
run_select_columns = filtered or None
with event_logger.log_context(action="mcp.list_rls_filters.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=run_select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"RLS filters listed: count=%s, total_count=%s"
% (len(result.rls_filters), result.total_count)
)
# Build column selection using ALL_RLS_COLUMNS as the source of truth,
# bypassing the USER_DIRECTORY_FIELDS privacy filter applied by
# ModelListCore. 'roles' in an RLS filter is which roles the filter
# applies to — core filter data — not user-directory metadata (like
# dashboard.roles, which exposes who has access to the resource).
if request.select_columns:
columns_to_filter = [
c for c in request.select_columns if c in ALL_RLS_COLUMNS
]
if not columns_to_filter:
columns_to_filter = list(DEFAULT_RLS_COLUMNS)
else:
columns_to_filter = list(DEFAULT_RLS_COLUMNS)
with event_logger.log_context(action="mcp.list_rls_filters.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"RLS filter listing failed: error=%s, error_type=%s"
% (str(e), type(e).__name__)
)
raise

View File

@@ -24,6 +24,7 @@ from __future__ import annotations
import builtins
import logging
import textwrap
import threading
from ast import literal_eval
from contextlib import closing, contextmanager, nullcontext, suppress
from copy import deepcopy
@@ -56,7 +57,7 @@ from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.exc import NoSuchModuleError
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Mapper, relationship
from sqlalchemy.pool import NullPool
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.sql import ColumnElement, expression, Select
@@ -94,6 +95,15 @@ from superset.utils.oauth2 import (
metadata = Model.metadata # pylint: disable=no-member
logger = logging.getLogger(__name__)
# Per-process SQLAlchemy engine cache (#27897). Key is
# (database_id, str(sqlalchemy_url), repr(sorted(engine_kwargs.items()))).
# Lock-guarded against the gunicorn-threaded check-then-set race on first
# access. Cache is per-process, per-(URL + final engine_kwargs), so a
# password rotation, host change, or DB_CONNECTION_MUTATOR producing
# different kwargs naturally falls through to a fresh engine.
_ENGINE_CACHE: dict[tuple[int, str, str], Engine] = {}
_ENGINE_CACHE_LOCK = threading.Lock()
if TYPE_CHECKING:
from superset_core.queries.types import AsyncQueryHandle, QueryOptions, QueryResult
@@ -495,7 +505,12 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
cursor.close()
sqla.event.listen(engine, "connect", run_prequeries)
yield engine
try:
yield engine
finally:
sqla.event.remove(engine, "connect", run_prequeries)
else:
yield engine
def _get_sqla_engine( # pylint: disable=too-many-locals # noqa: C901
self,
@@ -565,10 +580,36 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
security_manager,
source,
)
# Per-process engine cache (#27897). SQLAlchemy expects ``create_engine``
# to be called once per process per URL so its connection pool can do
# its job. Recreating the engine every call defeats the pool that
# operators configure via ``DB_CONNECTION_MUTATOR`` (e.g. duckdb with a
# size-1 queue). Cache regardless of ``nullpool``: even a NullPool
# engine has nontrivial construction cost (URL parsing, dialect
# resolution, connect_args setup, and re-running the mutator), and
# production callsites pass ``nullpool=True`` by default — gating the
# cache on ``not nullpool`` would leave it dormant everywhere it
# actually matters. Unsaved instances (``self.id is None``) are
# excluded so two distinct in-memory ``Database`` objects with the
# same URI can't collide on a shared cache entry.
cache_key: tuple[int, str, str] | None = None
if self.id is not None:
cache_key = (
self.id,
str(sqlalchemy_url),
repr(sorted(engine_kwargs.items())),
)
with _ENGINE_CACHE_LOCK:
if cached := _ENGINE_CACHE.get(cache_key):
return cached
try:
return create_engine(sqlalchemy_url, **engine_kwargs)
engine = create_engine(sqlalchemy_url, **engine_kwargs)
except Exception as ex:
raise self.db_engine_spec.get_dbapi_mapped_exception(ex) from ex
if cache_key is not None:
with _ENGINE_CACHE_LOCK:
_ENGINE_CACHE[cache_key] = engine
return engine
def add_database_to_signature(
self,
@@ -1343,6 +1384,30 @@ sqla.event.listen(Database, "after_update", security_manager.database_after_upda
sqla.event.listen(Database, "after_delete", security_manager.database_after_delete)
def _evict_engine_cache(
mapper: Mapper,
connection: Connection,
target: "Database",
) -> None:
"""Evict all cached engines for a database when it is updated or deleted.
URL/kwargs changes already produce a new cache key, so stale engines are
never served to callers. This eviction step is purely to reclaim memory:
without it, old engines for a renamed host or rotated password would linger
in _ENGINE_CACHE until the process restarted.
"""
if target.id is None:
return
with _ENGINE_CACHE_LOCK:
stale = [k for k in _ENGINE_CACHE if k[0] == target.id]
for k in stale:
_ENGINE_CACHE.pop(k, None)
sqla.event.listen(Database, "after_update", _evict_engine_cache)
sqla.event.listen(Database, "after_delete", _evict_engine_cache)
class DatabaseUserOAuth2Tokens(Model, AuditMixinNullable):
"""
Store OAuth2 tokens, for authenticating to DBs using user personal tokens.

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,172 @@
# 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 logging
from unittest.mock import MagicMock, Mock, patch
import pytest
from fastmcp import Client
from pydantic import ValidationError
from superset.mcp_service.app import mcp
from superset.mcp_service.plugin.schemas import ListPluginsRequest, PluginColumnFilter
from superset.utils import json
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def create_mock_plugin(
plugin_id: int = 1,
name: str = "My Plugin",
key: str = "my_plugin",
bundle_url: str = "https://example.com/plugin.js",
) -> MagicMock:
plugin = MagicMock()
plugin.id = plugin_id
plugin.name = name
plugin.key = key
plugin.bundle_url = bundle_url
plugin.changed_on = None
plugin.created_on = None
return plugin
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
mock_user = Mock()
mock_user.id = 1
mock_user.username = "admin"
mock_get_user.return_value = mock_user
yield mock_get_user
class TestPluginColumnFilterSchema:
def test_invalid_filter_column_rejected(self):
with pytest.raises(ValidationError):
PluginColumnFilter(col="bundle_url", opr="eq", value="test")
def test_valid_name_filter(self):
f = PluginColumnFilter(col="name", opr="eq", value="test")
assert f.col == "name"
def test_valid_key_filter(self):
f = PluginColumnFilter(col="key", opr="eq", value="my_plugin")
assert f.col == "key"
@patch("superset.mcp_service.plugin.dao.DynamicPluginDAO.list")
@pytest.mark.asyncio
async def test_list_plugins_basic(mock_list, mcp_server):
plugin = create_mock_plugin()
mock_list.return_value = ([plugin], 1)
async with Client(mcp_server) as client:
result = await client.call_tool("list_plugins", {})
data = json.loads(result.content[0].text)
assert "plugins" in data
assert len(data["plugins"]) == 1
assert data["plugins"][0]["id"] == 1
assert data["plugins"][0]["name"] == "My Plugin"
assert data["plugins"][0]["key"] == "my_plugin"
@patch("superset.mcp_service.plugin.dao.DynamicPluginDAO.list")
@pytest.mark.asyncio
async def test_list_plugins_with_request(mock_list, mcp_server):
plugin = create_mock_plugin()
mock_list.return_value = ([plugin], 1)
async with Client(mcp_server) as client:
request = ListPluginsRequest(page=1, page_size=10)
result = await client.call_tool(
"list_plugins", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["count"] == 1
assert data["total_count"] == 1
@patch("superset.mcp_service.plugin.dao.DynamicPluginDAO.list")
@pytest.mark.asyncio
async def test_list_plugins_with_search(mock_list, mcp_server):
plugin = create_mock_plugin(name="Custom Chart")
mock_list.return_value = ([plugin], 1)
async with Client(mcp_server) as client:
request = ListPluginsRequest(page=1, page_size=10, search="custom")
result = await client.call_tool(
"list_plugins", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["plugins"][0]["name"] == "Custom Chart"
@patch("superset.mcp_service.plugin.dao.DynamicPluginDAO.list")
@pytest.mark.asyncio
async def test_list_plugins_empty(mock_list, mcp_server):
mock_list.return_value = ([], 0)
async with Client(mcp_server) as client:
result = await client.call_tool("list_plugins", {})
data = json.loads(result.content[0].text)
assert data["count"] == 0
assert data["plugins"] == []
@patch("superset.mcp_service.plugin.dao.DynamicPluginDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_plugin_info_basic(mock_find, mcp_server):
plugin = create_mock_plugin()
mock_find.return_value = plugin
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_plugin_info", {"request": {"identifier": 1}}
)
data = json.loads(result.content[0].text)
assert data["id"] == 1
assert data["name"] == "My Plugin"
assert data["key"] == "my_plugin"
assert data["bundle_url"] == "https://example.com/plugin.js"
@patch("superset.mcp_service.plugin.dao.DynamicPluginDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_plugin_info_not_found(mock_find, mcp_server):
mock_find.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_plugin_info", {"request": {"identifier": 999}}
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "not_found"
def test_list_plugins_request_rejects_search_and_filters():
with pytest.raises(ValidationError):
ListPluginsRequest(
search="test",
filters=[{"col": "name", "opr": "eq", "value": "x"}],
)

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,245 @@
# 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 logging
from unittest.mock import MagicMock, Mock, patch
import pytest
from fastmcp import Client
from pydantic import ValidationError
from superset.mcp_service.app import mcp
from superset.mcp_service.rls.schemas import ListRlsFiltersRequest, RlsColumnFilter
from superset.utils import json
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def create_mock_rls_filter(
filter_id: int = 1,
name: str = "test_filter",
filter_type: str = "Regular",
clause: str = "user_id = {{current_user_id()}}",
group_key: str | None = None,
) -> MagicMock:
rls_filter = MagicMock()
rls_filter.id = filter_id
rls_filter.name = name
rls_filter.filter_type = filter_type
rls_filter.clause = clause
rls_filter.group_key = group_key
rls_filter.changed_on = None
table = MagicMock()
table.id = 1
table.table_name = "sales"
rls_filter.tables = [table]
role = MagicMock()
role.id = 1
role.name = "Alpha"
rls_filter.roles = [role]
return rls_filter
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
mock_user = Mock()
mock_user.id = 1
mock_user.username = "admin"
mock_get_user.return_value = mock_user
yield mock_get_user
class TestRlsColumnFilterSchema:
def test_invalid_filter_column_rejected(self):
with pytest.raises(ValidationError):
RlsColumnFilter(col="clause", opr="eq", value="test")
def test_valid_name_filter(self):
f = RlsColumnFilter(col="name", opr="eq", value="test")
assert f.col == "name"
def test_valid_filter_type_filter(self):
f = RlsColumnFilter(col="filter_type", opr="eq", value="Regular")
assert f.col == "filter_type"
@patch("superset.daos.security.RLSDAO.list")
@pytest.mark.asyncio
async def test_list_rls_filters_basic(mock_list, mcp_server):
rls_filter = create_mock_rls_filter()
mock_list.return_value = ([rls_filter], 1)
async with Client(mcp_server) as client:
result = await client.call_tool("list_rls_filters", {})
assert result.content is not None
data = json.loads(result.content[0].text)
assert "rls_filters" in data
assert len(data["rls_filters"]) == 1
assert data["rls_filters"][0]["id"] == 1
assert data["rls_filters"][0]["name"] == "test_filter"
@patch("superset.daos.security.RLSDAO.list")
@pytest.mark.asyncio
async def test_list_rls_filters_with_request(mock_list, mcp_server):
rls_filter = create_mock_rls_filter()
mock_list.return_value = ([rls_filter], 1)
async with Client(mcp_server) as client:
request = ListRlsFiltersRequest(page=1, page_size=10)
result = await client.call_tool(
"list_rls_filters", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["count"] == 1
assert data["total_count"] == 1
@patch("superset.daos.security.RLSDAO.list")
@pytest.mark.asyncio
async def test_list_rls_filters_with_search(mock_list, mcp_server):
rls_filter = create_mock_rls_filter(name="user_filter")
mock_list.return_value = ([rls_filter], 1)
async with Client(mcp_server) as client:
request = ListRlsFiltersRequest(page=1, page_size=10, search="user")
result = await client.call_tool(
"list_rls_filters", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["rls_filters"][0]["name"] == "user_filter"
@patch("superset.daos.security.RLSDAO.list")
@pytest.mark.asyncio
async def test_list_rls_filters_returns_tables_and_roles(mock_list, mcp_server):
rls_filter = create_mock_rls_filter()
mock_list.return_value = ([rls_filter], 1)
async with Client(mcp_server) as client:
request = ListRlsFiltersRequest(
page=1,
page_size=10,
select_columns=["id", "name", "tables", "roles"],
)
result = await client.call_tool(
"list_rls_filters", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
item = data["rls_filters"][0]
assert "tables" in item
assert item["tables"][0]["table_name"] == "sales"
assert "roles" in item
assert item["roles"][0]["name"] == "Alpha"
@patch("superset.daos.security.RLSDAO.list")
@pytest.mark.asyncio
async def test_list_rls_filters_empty(mock_list, mcp_server):
mock_list.return_value = ([], 0)
async with Client(mcp_server) as client:
result = await client.call_tool("list_rls_filters", {})
data = json.loads(result.content[0].text)
assert data["count"] == 0
assert data["rls_filters"] == []
@patch("superset.daos.security.RLSDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_rls_filter_info_basic(mock_find, mcp_server):
rls_filter = create_mock_rls_filter()
mock_find.return_value = rls_filter
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_rls_filter_info", {"request": {"identifier": 1}}
)
data = json.loads(result.content[0].text)
assert data["id"] == 1
assert data["name"] == "test_filter"
assert data["filter_type"] == "Regular"
assert data["clause"] == "user_id = {{current_user_id()}}"
@patch("superset.daos.security.RLSDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_rls_filter_info_not_found(mock_find, mcp_server):
mock_find.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_rls_filter_info", {"request": {"identifier": 999}}
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "not_found"
@patch("superset.daos.security.RLSDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_rls_filter_info_includes_tables_and_roles(mock_find, mcp_server):
rls_filter = create_mock_rls_filter()
mock_find.return_value = rls_filter
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_rls_filter_info", {"request": {"identifier": 1}}
)
data = json.loads(result.content[0].text)
assert data["tables"][0]["table_name"] == "sales"
assert data["roles"][0]["name"] == "Alpha"
def test_list_rls_filters_request_rejects_search_and_filters():
with pytest.raises(ValidationError):
ListRlsFiltersRequest(
search="test",
filters=[{"col": "name", "opr": "eq", "value": "x"}],
)
@patch("superset.daos.security.RLSDAO.list")
@pytest.mark.asyncio
async def test_list_rls_filters_roles_only_select_columns(mock_list, mcp_server):
"""Regression: select_columns=['roles'] must not raise ValueError.
'roles' is in USER_DIRECTORY_FIELDS so ModelListCore would raise if it
were the sole column passed to run_tool. The tool must strip it before
calling run_tool and restore it in the model_dump context.
"""
rls_filter = create_mock_rls_filter()
mock_list.return_value = ([rls_filter], 1)
async with Client(mcp_server) as client:
request = ListRlsFiltersRequest(page=1, page_size=10, select_columns=["roles"])
result = await client.call_tool(
"list_rls_filters", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
item = data["rls_filters"][0]
assert "roles" in item
assert item["roles"][0]["name"] == "Alpha"

View File

@@ -536,6 +536,97 @@ def test_get_sqla_engine(mocker: MockerFixture) -> None:
)
def test_get_sqla_engine_caches_engine_per_url(mocker: MockerFixture) -> None:
"""
Regression for #27897: a single SQLAlchemy ``Engine`` should be created per
process/URL, not on every ``_get_sqla_engine`` call.
Per the SQLAlchemy docs (https://docs.sqlalchemy.org/en/20/core/connections.html),
the engine is meant to be created once and reused so its connection pool
can do its job. Calling ``create_engine`` repeatedly defeats pooling, so
user-configured pools (e.g. via ``DB_CONNECTION_MUTATOR``) never persist
state between requests.
Exercises the production default path (``nullpool=True``) — every
in-tree callsite uses it — so the assertion would have caught a fix
that only engaged under ``nullpool=False``.
"""
from superset.models.core import _ENGINE_CACHE, Database
# Clear the process-wide cache so prior tests don't poison this assertion.
_ENGINE_CACHE.clear()
mocker.patch(
"superset.models.core.security_manager.find_user",
return_value=None,
)
create_engine = mocker.patch("superset.models.core.create_engine")
database = Database(database_name="my_db", sqlalchemy_uri="trino://")
database.id = 1 # Cache is keyed on id; skipped for unsaved instances.
database._get_sqla_engine()
database._get_sqla_engine()
assert create_engine.call_count == 1, (
"Database._get_sqla_engine should reuse the engine for the same URL "
f"(create_engine called {create_engine.call_count} times)"
)
def test_get_sqla_engine_does_not_cache_unsaved_instances(
mocker: MockerFixture,
) -> None:
"""
Two distinct unsaved ``Database`` instances (``id is None``) with the
same URI must not share a cache entry — they're different in-memory
objects and may have diverging config that isn't yet persisted.
"""
from superset.models.core import _ENGINE_CACHE, Database
_ENGINE_CACHE.clear()
mocker.patch(
"superset.models.core.security_manager.find_user",
return_value=None,
)
create_engine = mocker.patch("superset.models.core.create_engine")
Database(database_name="db_a", sqlalchemy_uri="trino://")._get_sqla_engine()
Database(database_name="db_b", sqlalchemy_uri="trino://")._get_sqla_engine()
assert create_engine.call_count == 2
assert _ENGINE_CACHE == {}
def test_engine_cache_evicted_on_update_and_delete(mocker: MockerFixture) -> None:
"""
Regression for #27897: engines cached for a database must be evicted when
that database is updated or deleted so that stale connections (old password,
old host, old SSH tunnel) do not linger in memory across config changes.
"""
from unittest.mock import MagicMock
from superset.models.core import (
_ENGINE_CACHE,
_ENGINE_CACHE_LOCK,
_evict_engine_cache,
)
# Seed the cache with two entries for database id=1 and one for id=2.
with _ENGINE_CACHE_LOCK:
_ENGINE_CACHE.clear()
_ENGINE_CACHE[(1, "postgresql://old-host/db", "")] = MagicMock()
_ENGINE_CACHE[(1, "postgresql://new-host/db", "")] = MagicMock()
_ENGINE_CACHE[(2, "postgresql://other/db", "")] = MagicMock()
db_instance = MagicMock()
db_instance.id = 1
_evict_engine_cache(mapper=None, connection=None, target=db_instance)
# Both id=1 entries gone; id=2 entry untouched.
assert not any(k[0] == 1 for k in _ENGINE_CACHE)
assert any(k[0] == 2 for k in _ENGINE_CACHE)
def test_get_sqla_engine_user_impersonation(mocker: MockerFixture) -> None:
"""
Test user impersonation in `_get_sqla_engine`.
@@ -637,6 +728,7 @@ def test_get_sqla_engine_registers_prequery_event_listener(
db_engine_spec = mocker.patch.object(Database, "db_engine_spec")
db_engine_spec.get_prequeries.return_value = ['SET search_path = "my_schema"']
event_listen = mocker.patch("superset.models.core.sqla.event.listen")
mocker.patch("superset.models.core.sqla.event.remove")
database = Database(database_name="my_db", sqlalchemy_uri="postgresql://")
with database.get_sqla_engine(catalog="my_catalog", schema="my_schema"):
@@ -671,6 +763,7 @@ def test_get_sqla_engine_prequery_cursor_closed_on_exception(
db_engine_spec = mocker.patch.object(Database, "db_engine_spec")
db_engine_spec.get_prequeries.return_value = ['SET search_path = "bad_schema"']
event_listen = mocker.patch("superset.models.core.sqla.event.listen")
mocker.patch("superset.models.core.sqla.event.remove")
database = Database(database_name="my_db", sqlalchemy_uri="postgresql://")
with database.get_sqla_engine(catalog=None, schema="bad_schema"):
@@ -734,6 +827,7 @@ def test_get_raw_connection_executes_prequeries_exactly_once(
original_listen.side_effect = lambda engine, event, fn: captured_listeners.append(
fn
)
mocker.patch("superset.models.core.sqla.event.remove")
# Simulate SQLAlchemy firing the "connect" event when raw_connection() is called.
mock_dbapi_conn = mocker.MagicMock()