Compare commits

..

1 Commits

Author SHA1 Message Date
Amin Ghadersohi
9b6ee2522c feat(mcp): add create_css_template and update_css_template tools
- Add CreateCssTemplateCommand, UpdateCssTemplateCommand with exceptions
- Add create_css_template and update_css_template MCP tools with Pydantic
  schemas, event logging, and structured error responses
- Add sanitize_error_for_llm_context validator on error fields (CWE-79)
- Add unit tests for both tools including schema validation and exception
  re-raise coverage
2026-05-30 04:40:47 +00:00
93 changed files with 2137 additions and 4545 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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: ${{ inputs.cache }}

View File

@@ -23,7 +23,6 @@ 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: 7
default-days: 5
- package-ecosystem: "npm"
ignore:
@@ -59,7 +59,7 @@ updates:
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "pip"
@@ -76,7 +76,7 @@ updates:
- pip
- dependabot
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: ".github/actions"
@@ -85,7 +85,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/docs/"
@@ -110,7 +110,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-websocket/"
@@ -121,7 +121,7 @@ updates:
- dependabot
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- 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: 7
default-days: 5
# Now for all of our plugins and packages!
@@ -147,7 +147,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5
- 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: 7
default-days: 5

43
.github/workflows/cancel_duplicates.yml vendored Normal file
View File

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

View File

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

View File

@@ -28,8 +28,6 @@ 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
@@ -52,8 +50,6 @@ 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,8 +34,6 @@ 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,8 +22,6 @@ 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

@@ -0,0 +1,83 @@
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.'
})

350
.github/workflows/ephemeral-env.yml vendored Normal file
View File

@@ -0,0 +1,350 @@
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,11 +16,6 @@ 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@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
- uses: actions/labeler@v6
with:
sync-labels: true

View File

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

View File

@@ -6,9 +6,6 @@ on:
- "master"
- "[0-9].[0-9]*"
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -30,12 +27,9 @@ 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,9 +27,6 @@ concurrency:
group: docs-deploy-asf-site
cancel-in-progress: true
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -16,9 +16,6 @@ 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
@@ -28,8 +25,6 @@ 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 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
file: ./coverage.xml
flags: superset-extensions-cli

View File

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

View File

@@ -21,9 +21,6 @@ on:
options:
- 'true'
- 'false'
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -45,8 +42,6 @@ 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"]
@@ -56,7 +51,6 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Setup Docker Environment
@@ -83,9 +77,8 @@ 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
@@ -121,7 +114,6 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Use Node.js 20
@@ -136,12 +128,11 @@ 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,8 +33,6 @@ 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@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
uses: actions/first-interaction@v3
with:
repo_token: ${{ github.token }}
issue_message: |-

View File

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

View File

@@ -14721,10 +14721,15 @@ 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@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==
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==
uvu@^0.5.0:
version "0.5.6"
@@ -15129,9 +15134,9 @@ ws@^7.3.1:
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.18.0, ws@^8.2.3:
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==
version "8.18.3"
resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
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.12
mako==1.3.11
# 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.7.0
urllib3==2.6.3
# via
# -r requirements/base.in
# requests

View File

@@ -346,7 +346,6 @@ 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
@@ -361,7 +360,7 @@ google-cloud-bigquery==3.27.0
# apache-superset
# pandas-gbq
# sqlalchemy-bigquery
google-cloud-bigquery-storage==2.26.0
google-cloud-bigquery-storage==2.19.1
# via pandas-gbq
google-cloud-core==2.4.1
# via google-cloud-bigquery
@@ -507,7 +506,7 @@ limits==5.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
mako==1.3.12
mako==1.3.11
# via
# -c requirements/base-constraint.txt
# alembic
@@ -702,7 +701,7 @@ proto-plus==1.25.0
# via
# google-api-core
# google-cloud-bigquery-storage
protobuf==5.29.6
protobuf==4.25.8
# via
# google-api-core
# google-cloud-bigquery-storage
@@ -840,7 +839,7 @@ python-dotenv==1.2.2
# pydantic-settings
python-ldap==3.4.4
# via apache-superset
python-multipart==0.0.29
python-multipart==0.0.20
# via mcp
pytz==2025.2
# via
@@ -1072,7 +1071,7 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.7.0
urllib3==2.6.3
# 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
}
if [ -n "$output" ]; then echo "$output"; fi
[ -n "$output" ] && echo "$output"
else
echo "No JavaScript/TypeScript files to lint"
fi

View File

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

View File

@@ -1,102 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, 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,13 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, useState } from 'react';
import { useState, useMemo } from 'react';
import { t } from '@apache-superset/core/translation';
import type { SelectOption } from './types';
import { styled } from '@apache-superset/core/theme';
import { FormLabel, Select } from '@superset-ui/core/components';
import { SELECT_WIDTH } from './utils';
import { CardSortSelectOption, SortColumn } from './types';
import CompactFilterTrigger from './Filters/CompactFilterTrigger';
import CompactSelectPanel from './Filters/CompactSelectPanel';
import type { FilterHandler } from './Filters/types';
const SortContainer = styled.div`
display: inline-flex;
font-size: ${({ theme }) => theme.fontSizeSM}px;
align-items: center;
text-align: left;
width: ${SELECT_WIDTH}px;
`;
interface CardViewSelectSortProps {
onChange: (value: SortColumn[]) => void;
@@ -35,8 +42,6 @@ export const CardSortSelect = ({
onChange,
options,
}: CardViewSelectSortProps) => {
const panelRef = useRef<FilterHandler>(null);
const defaultSort =
(initialSort &&
options.find(
@@ -45,41 +50,44 @@ export const CardSortSelect = ({
)) ||
options[0];
const [currentValue, setCurrentValue] = useState<SelectOption>({
const [value, setValue] = useState({
label: defaultSort.label,
value: defaultSort.value,
});
const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
const formattedOptions = useMemo(
() => options.map(option => ({ label: option.label, value: option.value })),
[options],
);
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 }]);
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);
}
};
return (
<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>
<SortContainer>
<Select
ariaLabel={t('Sort')}
header={<FormLabel>{t('Sort')}</FormLabel>}
labelInValue
onChange={handleOnChange}
options={formattedOptions}
showSearch
value={value}
data-test="card-sort-select"
/>
</SortContainer>
);
};

View File

@@ -1,145 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, 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

@@ -1,198 +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 {
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

@@ -1,339 +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, 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

@@ -1,318 +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 {
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

@@ -0,0 +1,112 @@
/**
* 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

@@ -1,80 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, 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

@@ -1,74 +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 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

@@ -0,0 +1,267 @@
/**
* 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

@@ -0,0 +1,154 @@
/**
* 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

@@ -1,251 +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, 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

@@ -1,291 +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 {
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,7 +17,6 @@
* 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';
@@ -98,335 +97,7 @@ test('search filter passes autoComplete prop correctly', () => {
expect(input.autocomplete).toBe('new-password');
});
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', () => {
test('renders multiple search filters with different inputName values', () => {
const filters = [
{
Header: 'Name',
@@ -454,8 +125,8 @@ test('renders only the first search filter when multiple search filters are conf
/>,
);
// Only the first search filter renders — one search box per page
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
expect(inputs).toHaveLength(1);
expect(inputs).toHaveLength(2);
expect(inputs[0].name).toBe('filter_name_search');
expect(inputs[1].name).toBe('description');
});

View File

@@ -19,16 +19,12 @@
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,
@@ -37,13 +33,10 @@ 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;
@@ -53,10 +46,7 @@ interface UIFiltersProps {
function UIFilters(
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
ref: RefObject<{
clearFilters: () => void;
clearFilterById: (id: string) => void;
}>,
ref: RefObject<{ clearFilters: () => void }>,
) {
const filterRefs = useMemo(
() =>
@@ -64,320 +54,125 @@ 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((_, index) => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
filterRefs.forEach((filter: any) => {
filter.current?.clearFilter?.();
});
setTooltipLabels({});
setTimeRangeTooltips({});
},
clearFilterById: (id: string) => {
const index = filters.findIndex(f => f.id === id);
if (index >= 0) {
clearFilterAtIndex(index);
filterRefs[index]?.current?.clearFilter?.();
}
},
}));
// 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
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
ref={filterRefs[index]}
selects={selects}
Header={Header}
fetchSelects={fetchSelects}
value={initialValue as SelectOption | undefined}
loading={loading ?? false}
isOpen={isOpen}
onClose={onClose}
panelStyle={popupStyle}
initialValue={initialValue}
key={key}
name={id}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
if (option && !isClear) {
setTooltipLabels(prev => ({
...prev,
[index]:
typeof option.label === 'string'
? option.label
: String(option.value ?? ''),
}));
}
if (onFilterUpdate && !isClear) {
onFilterUpdate(option);
if (onFilterUpdate) {
// Filter change triggers both onChange AND onClear, only want to track onChange
if (!isClear) {
onFilterUpdate(option);
}
}
updateFilterValue(index, option);
}}
optionFilterProps={optionFilterProps}
paginate={paginate}
selects={selects}
loading={loading ?? false}
dropdownStyle={popupStyle}
/>
)}
</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 {
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);
}
updateFilterValue(index, value);
}
}}
/>
)}
</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}>
}}
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 (
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
</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,
);
}
return null;
},
)}
</>
);

View File

@@ -301,19 +301,15 @@ describe('ListView', () => {
});
test('renders UI filters', () => {
// 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
const filterControls = screen.getAllByRole('combobox');
expect(filterControls).toHaveLength(2);
});
test('calls fetchData on filter', async () => {
// 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' });
// Handle select filter
const selectFilter = screen.getAllByRole('combobox')[0];
await userEvent.click(selectFilter);
const option = screen.getByText('foo');
await userEvent.click(option);
// Handle search filter
@@ -345,10 +341,7 @@ describe('ListView', () => {
initialSort: [{ id: 'something' }],
});
const sortSelectContainer = screen.getByTestId('card-sort-select');
const sortSelect = sortSelectContainer.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement;
const sortSelect = screen.getByTestId('card-sort-select');
await userEvent.click(sortSelect);
const sortOption = screen.getByText('Alphabetical');

View File

@@ -65,43 +65,13 @@ const ListViewStyles = styled.div`
.header {
display: flex;
align-items: center;
padding-bottom: ${theme.sizeUnit * 4}px;
& .controls {
display: flex;
flex-wrap: wrap;
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;
}
column-gap: ${theme.sizeUnit * 7}px;
row-gap: ${theme.sizeUnit * 4}px;
}
}
@@ -197,6 +167,7 @@ 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;
@@ -221,29 +192,6 @@ 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;
@@ -408,14 +356,6 @@ 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.
@@ -481,21 +421,6 @@ 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,12 +57,9 @@ const mockUser = {
const findFilterByLabel = (labelText: string) => {
const containers = screen.getAllByTestId('select-filter-container');
for (const container of containers) {
// 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;
const label = container.querySelector('label');
if (label?.textContent === labelText) {
return container.querySelector('[role="combobox"], .ant-select');
}
}
return null;

View File

@@ -156,16 +156,18 @@ describe('DashboardList Card View Tests', () => {
).toBeInTheDocument();
});
// Find the sort select by its testId, then the pill button within it
// Find the sort select by its testId, then the combobox within it
const sortContainer = screen.getByTestId('card-sort-select');
// eslint-disable-next-line testing-library/no-node-access
const sortPill = sortContainer.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement;
await userEvent.click(sortPill);
const sortCombobox = within(sortContainer).getByRole('combobox');
await userEvent.click(sortCombobox);
// Select "Alphabetical" from the dropdown
const alphabeticalOption = await screen.findByText('Alphabetical');
const alphabeticalOption = await waitFor(() =>
within(
// eslint-disable-next-line testing-library/no-node-access
document.querySelector('.rc-virtual-list')!,
).getByText('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,
selectPillOption,
selectOption,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
@@ -200,7 +200,7 @@ test('selecting Status filter encodes published=true in API call', async () => {
).toBeInTheDocument();
});
await selectPillOption('Published', 'Status');
await selectOption('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 selectPillOption('Admin User', 'Owner');
await selectOption('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 selectPillOption('Admin User', 'Modified by');
await selectOption('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 { selectPillOption } from 'spec/helpers/testing-library';
import { selectOption } 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 selectPillOption helper (compact pill UI)
// 2. Apply a filter using selectOption helper
const beforeFilterCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
await selectPillOption('Virtual', 'Type');
await selectOption('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 { selectPillOption } from 'spec/helpers/testing-library';
import { selectOption } from 'spec/helpers/testing-library';
import {
setupMocks,
renderDatasetList,
@@ -1510,8 +1510,11 @@ test('bulk selection clears when filter changes', async () => {
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply a filter using selectPillOption helper (compact pill UI)
await selectPillOption('Virtual', 'Type');
// 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');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1553,13 +1556,16 @@ 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 using compact pill UI
await selectPillOption('Virtual', 'Type');
// Apply Type filter
await selectOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1600,13 +1606,16 @@ 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 using compact pill UI
await selectPillOption('Virtual', 'Type');
// Apply Type filter
await selectOption('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 pills should be present (compact pill UI)
const filters = await screen.findAllByTestId('compact-filter-pill');
// Filter dropdowns should be present
const filters = await screen.findAllByRole('combobox');
expect(filters.length).toBeGreaterThan(0);
});
@@ -445,8 +445,7 @@ test('selecting Database filter triggers API call with database relation filter'
await waitForDatasetsPageReady();
// Filter pills should be present (compact pill UI replaces comboboxes)
const filtersContainers = screen.getAllByTestId('compact-filter-pill');
const filtersContainers = screen.getAllByRole('combobox');
expect(filtersContainers.length).toBeGreaterThan(0);
});

View File

@@ -121,17 +121,13 @@ describe('GroupsList', () => {
test('renders the filters correctly', async () => {
await renderComponent();
const filtersSelect = screen.getAllByTestId('filters-select')[0];
// 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);
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();
});
test('renders correct columns in the table', async () => {

View File

@@ -151,11 +151,8 @@ describe('RolesList', () => {
test('renders filters options', async () => {
await renderAndWait();
// 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);
const typeFilter = screen.queryAllByTestId('filters-select');
expect(typeFilter).toHaveLength(4);
});
test('renders correct list columns', async () => {

View File

@@ -166,14 +166,11 @@ 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(1);
expect(searchFilters).toHaveLength(2);
// Select filters render as compact pill buttons (Filter Type, Modified by)
const selectContainers = screen.queryAllByTestId('select-filter-container');
expect(selectContainers).toHaveLength(2);
const typeFilter = screen.queryAllByTestId('filters-select');
expect(typeFilter).toHaveLength(3); // Update to expect 3 select filters
});
test('renders correct list columns', async () => {

View File

@@ -138,16 +138,16 @@ describe('UsersList', () => {
test('renders filters options', async () => {
await renderAndWait();
// 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);
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();
});
test('renders correct list columns', async () => {

View File

@@ -0,0 +1,46 @@
# 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 functools import partial
from typing import Any
from superset.commands.base import BaseCommand
from superset.commands.css.exceptions import (
CssTemplateCreateFailedError,
CssTemplateInvalidError,
)
from superset.daos.css import CssTemplateDAO
from superset.models.core import CssTemplate
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
class CreateCssTemplateCommand(BaseCommand):
def __init__(self, properties: dict[str, Any]):
self._properties = properties
@transaction(on_error=partial(on_error, reraise=CssTemplateCreateFailedError))
def run(self) -> CssTemplate:
self.validate()
return CssTemplateDAO.create(attributes=self._properties)
def validate(self) -> None:
if not self._properties.get("template_name", "").strip():
raise CssTemplateInvalidError()
if "css" not in self._properties:
raise CssTemplateInvalidError()

View File

@@ -16,7 +16,13 @@
# under the License.
from flask_babel import lazy_gettext as _
from superset.commands.exceptions import CommandException, DeleteFailedError
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
UpdateFailedError,
)
class CssTemplateDeleteFailedError(DeleteFailedError):
@@ -25,3 +31,15 @@ class CssTemplateDeleteFailedError(DeleteFailedError):
class CssTemplateNotFoundError(CommandException):
message = _("CSS template not found.")
class CssTemplateCreateFailedError(CreateFailedError):
message = _("CSS template could not be created.")
class CssTemplateInvalidError(CommandInvalidError):
message = _("CSS template parameters are invalid.")
class CssTemplateUpdateFailedError(UpdateFailedError):
message = _("CSS template could not be updated.")

View File

@@ -0,0 +1,53 @@
# 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 functools import partial
from typing import Any
from superset.commands.base import BaseCommand
from superset.commands.css.exceptions import (
CssTemplateInvalidError,
CssTemplateNotFoundError,
CssTemplateUpdateFailedError,
)
from superset.daos.css import CssTemplateDAO
from superset.models.core import CssTemplate
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
class UpdateCssTemplateCommand(BaseCommand):
def __init__(self, model_id: int, properties: dict[str, Any]):
self._model_id = model_id
self._properties = properties
self._model: CssTemplate | None = None
@transaction(on_error=partial(on_error, reraise=CssTemplateUpdateFailedError))
def run(self) -> CssTemplate:
self.validate()
assert self._model
return CssTemplateDAO.update(self._model, attributes=self._properties)
def validate(self) -> None:
self._model = CssTemplateDAO.find_by_id(self._model_id)
if not self._model:
raise CssTemplateNotFoundError()
template_name = self._properties.get("template_name")
if template_name is not None and not template_name.strip():
raise CssTemplateInvalidError()

View File

@@ -148,6 +148,8 @@ Database Connections:
CSS Templates:
- list_css_templates: List CSS templates with advanced filters (1-based pagination)
- get_css_template_info: Get CSS template details by ID (includes full css content)
- create_css_template: Create a new named CSS template for dashboard styling
- update_css_template: Update an existing CSS template's name or CSS content
Themes:
- list_themes: List themes with advanced filters (1-based pagination)
@@ -159,14 +161,6 @@ 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)
@@ -409,10 +403,9 @@ IMPORTANT - Tool-Only Interaction:
General usage tips:
- All listing tools use 1-based pagination (first page is 1)
- 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 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 '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
@@ -694,8 +687,10 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
update_chart_preview,
)
from superset.mcp_service.css_template.tool import ( # noqa: F401, E402
create_css_template,
get_css_template_info,
list_css_templates,
update_css_template,
)
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
add_chart_to_existing_dashboard,
@@ -717,18 +712,10 @@ 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

@@ -278,3 +278,107 @@ def serialize_css_template_object(obj: Any) -> CssTemplateInfo | None:
changed_by_name=getattr(obj, "changed_by_name", None) or None,
)
)
class CreateCssTemplateRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
template_name: str = Field(
...,
min_length=1,
max_length=250,
description="Name for the CSS template.",
)
css: str = Field(
...,
description="CSS content for the template.",
)
@field_validator("template_name")
@classmethod
def template_name_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("template_name must not be empty")
return v.strip()
class CreateCssTemplateResponse(BaseModel):
"""Response schema for create_css_template."""
id: int | None = Field(
None,
description="ID of the created CSS template. None if creation failed.",
)
template_name: str | None = Field(
None,
description="Name of the created CSS template.",
)
css: str | None = Field(
None,
description="CSS content of the created template.",
)
error: str | None = Field(
None,
description="Error message if creation failed, otherwise null.",
)
@field_validator("error")
@classmethod
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
"""Sanitize error text before it is exposed to LLM context."""
if value is None:
return value
return sanitize_for_llm_context(value, field_path=("error",))
class UpdateCssTemplateRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: int = Field(..., description="ID of the CSS template to update.")
template_name: str | None = Field(
None,
max_length=250,
description="New name for the CSS template.",
)
css: str | None = Field(
None,
description="New CSS content for the template.",
)
@field_validator("template_name")
@classmethod
def template_name_must_not_be_empty(cls, v: str | None) -> str | None:
if v is not None:
if not v.strip():
raise ValueError("template_name must not be empty")
return v.strip()
return v
class UpdateCssTemplateResponse(BaseModel):
"""Response schema for update_css_template."""
id: int | None = Field(
None,
description="ID of the updated CSS template. None if update failed.",
)
template_name: str | None = Field(
None,
description="Name of the updated CSS template.",
)
css: str | None = Field(
None,
description="CSS content of the updated template.",
)
error: str | None = Field(
None,
description="Error message if update failed, otherwise null.",
)
@field_validator("error")
@classmethod
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
"""Sanitize error text before it is exposed to LLM context."""
if value is None:
return value
return sanitize_for_llm_context(value, field_path=("error",))

View File

@@ -15,10 +15,14 @@
# specific language governing permissions and limitations
# under the License.
from .create_css_template import create_css_template
from .get_css_template_info import get_css_template_info
from .list_css_templates import list_css_templates
from .update_css_template import update_css_template
__all__ = [
"list_css_templates",
"get_css_template_info",
"create_css_template",
"update_css_template",
]

View File

@@ -0,0 +1,95 @@
# 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 fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.css_template.schemas import (
CreateCssTemplateRequest,
CreateCssTemplateResponse,
)
@tool(
tags=["mutate"],
class_permission_name="CssTemplate",
method_permission_name="write",
annotations=ToolAnnotations(
title="Create CSS template",
readOnlyHint=False,
destructiveHint=False,
),
)
async def create_css_template(
request: CreateCssTemplateRequest, ctx: Context
) -> CreateCssTemplateResponse:
"""Create a new CSS template that can be applied to dashboards.
Use this tool when a user wants to save a CSS stylesheet as a named
template for reuse across multiple dashboards.
The returned ``id`` can be used when configuring dashboard appearance.
"""
await ctx.info("Creating CSS template: template_name=%r" % (request.template_name,))
try:
from superset.commands.css.create import CreateCssTemplateCommand
from superset.commands.css.exceptions import (
CssTemplateCreateFailedError,
CssTemplateInvalidError,
)
with event_logger.log_context(action="mcp.create_css_template.create"):
template = CreateCssTemplateCommand(
{
"template_name": request.template_name,
"css": request.css,
}
).run()
await ctx.info(
"CSS template created: id=%s, template_name=%r"
% (template.id, template.template_name)
)
return CreateCssTemplateResponse(
id=template.id,
template_name=template.template_name,
css=template.css,
)
except CssTemplateInvalidError as exc:
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
return CreateCssTemplateResponse(
template_name=request.template_name,
css=request.css,
error=str(exc),
)
except CssTemplateCreateFailedError as exc:
await ctx.error("CSS template creation failed: %s" % (str(exc),))
return CreateCssTemplateResponse(
template_name=request.template_name,
css=request.css,
error=f"Failed to create CSS template: {exc}",
)
except Exception as exc:
await ctx.error(
"Unexpected error creating CSS template: %s: %s"
% (type(exc).__name__, str(exc))
)
raise

View File

@@ -0,0 +1,111 @@
# 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 typing import Any
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.css_template.schemas import (
UpdateCssTemplateRequest,
UpdateCssTemplateResponse,
)
@tool(
tags=["mutate"],
class_permission_name="CssTemplate",
method_permission_name="write",
annotations=ToolAnnotations(
title="Update CSS template",
readOnlyHint=False,
destructiveHint=False,
),
)
async def update_css_template(
request: UpdateCssTemplateRequest, ctx: Context
) -> UpdateCssTemplateResponse:
"""Update an existing CSS template's name or CSS content.
Use this tool when a user wants to rename a CSS template or replace its
CSS content. At least one of ``template_name`` or ``css`` must be provided.
The template is identified by its ``id``.
"""
await ctx.info(
"Updating CSS template: id=%s, fields=%r"
% (
request.id,
[f for f in ("template_name", "css") if getattr(request, f) is not None],
)
)
try:
from superset.commands.css.exceptions import (
CssTemplateInvalidError,
CssTemplateNotFoundError,
CssTemplateUpdateFailedError,
)
from superset.commands.css.update import UpdateCssTemplateCommand
properties: dict[str, Any] = {}
if request.template_name is not None:
properties["template_name"] = request.template_name
if request.css is not None:
properties["css"] = request.css
if not properties:
return UpdateCssTemplateResponse(
error="At least one of template_name or css must be provided.",
)
with event_logger.log_context(action="mcp.update_css_template.update"):
template = UpdateCssTemplateCommand(request.id, properties).run()
await ctx.info(
"CSS template updated: id=%s, template_name=%r"
% (template.id, template.template_name)
)
return UpdateCssTemplateResponse(
id=template.id,
template_name=template.template_name,
css=template.css,
)
except CssTemplateNotFoundError:
await ctx.warning("CSS template not found: id=%s" % (request.id,))
return UpdateCssTemplateResponse(
error="CSS template not found: %s" % (request.id,),
)
except CssTemplateInvalidError as exc:
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
return UpdateCssTemplateResponse(
error=str(exc),
)
except CssTemplateUpdateFailedError as exc:
await ctx.error("CSS template update failed: %s" % (str(exc),))
return UpdateCssTemplateResponse(
error="Failed to update CSS template: %s" % (exc,),
)
except Exception as exc:
await ctx.error(
"Unexpected error updating CSS template: %s: %s"
% (type(exc).__name__, str(exc))
)
raise

View File

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

View File

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

View File

@@ -1,213 +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.
"""
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

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

View File

@@ -1,101 +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.
"""
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

@@ -1,123 +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.
"""
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, owners, or access metadata."""
"""Remove fields that expose users, roles, owners, or access metadata."""
return {
key: value for key, value in data.items() if key not in USER_DIRECTORY_FIELDS
}

View File

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

View File

@@ -1,255 +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.
"""
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

@@ -1,24 +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.
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

@@ -1,101 +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.
"""
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

@@ -1,147 +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.
"""
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,7 +24,6 @@ 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
@@ -57,7 +56,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 Mapper, relationship
from sqlalchemy.orm import relationship
from sqlalchemy.pool import NullPool
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.sql import ColumnElement, expression, Select
@@ -95,15 +94,6 @@ 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
@@ -505,12 +495,7 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
cursor.close()
sqla.event.listen(engine, "connect", run_prequeries)
try:
yield engine
finally:
sqla.event.remove(engine, "connect", run_prequeries)
else:
yield engine
yield engine
def _get_sqla_engine( # pylint: disable=too-many-locals # noqa: C901
self,
@@ -580,36 +565,10 @@ 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:
engine = create_engine(sqlalchemy_url, **engine_kwargs)
return 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,
@@ -1384,30 +1343,6 @@ 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

@@ -119,21 +119,14 @@ class TaskResponseSchema(Schema):
return obj.payload_dict # type: ignore[attr-defined]
def get_properties(self, obj: object) -> dict[str, object]:
"""Get properties dict, filtering debugging details when SHOW_STACKTRACE
is disabled."""
"""Get properties dict, filtering stack_trace if SHOW_STACKTRACE is disabled."""
from flask import current_app
properties = dict(obj.properties_dict) # type: ignore[attr-defined]
# Remove internal debugging details unless SHOW_STACKTRACE is enabled.
# The full traceback and the raw exception class name disclose internal
# file paths, library versions, and architecture details (CWE-209), so
# they are gated behind the same flag that controls stack traces
# elsewhere in Superset. ``error_message`` is left in place as the
# consumer-facing failure reason.
# Remove stack_trace unless SHOW_STACKTRACE is enabled
if not current_app.config.get("SHOW_STACKTRACE", False):
properties.pop("stack_trace", None)
properties.pop("exception_type", None)
return properties

View File

@@ -0,0 +1,192 @@
# 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 unittest.mock import MagicMock, Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
from superset.mcp_service.css_template.schemas import CreateCssTemplateRequest
from superset.utils import json
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
"""Mock authentication for all tests."""
with patch(
"superset.mcp_service.auth.get_user_from_request",
return_value=Mock(is_authenticated=True),
):
yield
# ---------------------------------------------------------------------------
# Schema tests
# ---------------------------------------------------------------------------
def test_create_css_template_request_valid() -> None:
req = CreateCssTemplateRequest(
template_name="My Theme",
css=".header { color: red; }",
)
assert req.template_name == "My Theme"
assert req.css == ".header { color: red; }"
def test_create_css_template_request_strips_name_whitespace() -> None:
req = CreateCssTemplateRequest(
template_name=" My Theme ",
css=".header { color: red; }",
)
assert req.template_name == "My Theme"
def test_create_css_template_request_empty_name_fails() -> None:
from pydantic import ValidationError
with pytest.raises(ValidationError, match="template_name must not be empty"):
CreateCssTemplateRequest(template_name=" ", css=".header { color: red; }")
def test_create_css_template_request_name_too_long() -> None:
from pydantic import ValidationError
with pytest.raises(ValidationError):
CreateCssTemplateRequest(template_name="a" * 251, css="")
# ---------------------------------------------------------------------------
# Tool logic tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_css_template_success(mcp_server: object) -> None:
"""Happy path: template created, id and fields returned."""
mock_template = MagicMock()
mock_template.id = 7
mock_template.template_name = "Dark Theme"
mock_template.css = "body { background: #000; }"
mock_command = MagicMock()
mock_command.run.return_value = mock_template
with patch(
"superset.commands.css.create.CreateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
request = CreateCssTemplateRequest(
template_name="Dark Theme",
css="body { background: #000; }",
)
result = await client.call_tool(
"create_css_template", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["id"] == 7
assert data["template_name"] == "Dark Theme"
assert data["css"] == "body { background: #000; }"
assert data["error"] is None
@pytest.mark.asyncio
async def test_create_css_template_invalid_error(mcp_server: object) -> None:
"""CssTemplateInvalidError is caught and returned as an error response."""
from superset.commands.css.exceptions import CssTemplateInvalidError
mock_command = MagicMock()
mock_command.run.side_effect = CssTemplateInvalidError()
with patch(
"superset.commands.css.create.CreateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
request = CreateCssTemplateRequest(
template_name="Bad Template",
css="",
)
result = await client.call_tool(
"create_css_template", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["id"] is None
assert data["error"] is not None
@pytest.mark.asyncio
async def test_create_css_template_create_failed(mcp_server: object) -> None:
"""CssTemplateCreateFailedError is caught and returned as an error response."""
from superset.commands.css.exceptions import CssTemplateCreateFailedError
mock_command = MagicMock()
mock_command.run.side_effect = CssTemplateCreateFailedError()
with patch(
"superset.commands.css.create.CreateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
request = CreateCssTemplateRequest(
template_name="Failing Template",
css=".x { color: blue; }",
)
result = await client.call_tool(
"create_css_template", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["id"] is None
assert data["error"] is not None
assert "Failed to create CSS template" in data["error"]
@pytest.mark.asyncio
async def test_create_css_template_unexpected_exception_is_reraised(
mcp_server: object,
) -> None:
"""Unexpected exceptions are re-raised (not swallowed as error responses)."""
from fastmcp.exceptions import ToolError
mock_command = MagicMock()
mock_command.run.side_effect = RuntimeError("unexpected database error")
with patch(
"superset.commands.css.create.CreateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
with pytest.raises((RuntimeError, ToolError)):
await client.call_tool(
"create_css_template",
{
"request": {
"template_name": "Theme",
"css": ".x { color: red; }",
}
},
)

View File

@@ -0,0 +1,219 @@
# 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 unittest.mock import MagicMock, Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
from superset.mcp_service.css_template.schemas import UpdateCssTemplateRequest
from superset.utils import json
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
"""Mock authentication for all tests."""
with patch(
"superset.mcp_service.auth.get_user_from_request",
return_value=Mock(is_authenticated=True),
):
yield
# ---------------------------------------------------------------------------
# Schema tests
# ---------------------------------------------------------------------------
def test_update_css_template_request_valid_name_only() -> None:
req = UpdateCssTemplateRequest(id=1, template_name="New Name")
assert req.id == 1
assert req.template_name == "New Name"
assert req.css is None
def test_update_css_template_request_valid_css_only() -> None:
req = UpdateCssTemplateRequest(id=5, css=".body { color: blue; }")
assert req.id == 5
assert req.css == ".body { color: blue; }"
assert req.template_name is None
def test_update_css_template_request_strips_name_whitespace() -> None:
req = UpdateCssTemplateRequest(id=1, template_name=" Padded ")
assert req.template_name == "Padded"
def test_update_css_template_request_empty_name_fails() -> None:
from pydantic import ValidationError
with pytest.raises(ValidationError, match="template_name must not be empty"):
UpdateCssTemplateRequest(id=1, template_name=" ")
def test_update_css_template_request_name_too_long() -> None:
from pydantic import ValidationError
with pytest.raises(ValidationError):
UpdateCssTemplateRequest(id=1, template_name="x" * 251)
# ---------------------------------------------------------------------------
# Tool logic tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_css_template_success(mcp_server: object) -> None:
"""Happy path: template updated, all fields returned."""
mock_template = MagicMock()
mock_template.id = 3
mock_template.template_name = "Updated Theme"
mock_template.css = "body { background: #fff; }"
mock_command = MagicMock()
mock_command.run.return_value = mock_template
with patch(
"superset.commands.css.update.UpdateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
request = UpdateCssTemplateRequest(
id=3,
template_name="Updated Theme",
css="body { background: #fff; }",
)
result = await client.call_tool(
"update_css_template", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["id"] == 3
assert data["template_name"] == "Updated Theme"
assert data["css"] == "body { background: #fff; }"
assert data["error"] is None
@pytest.mark.asyncio
async def test_update_css_template_no_fields_returns_error(
mcp_server: object,
) -> None:
"""Calling with neither template_name nor css returns a structured error."""
async with Client(mcp_server) as client:
result = await client.call_tool("update_css_template", {"request": {"id": 1}})
data = json.loads(result.content[0].text)
assert data["id"] is None
assert "At least one" in data["error"]
@pytest.mark.asyncio
async def test_update_css_template_not_found(mcp_server: object) -> None:
"""CssTemplateNotFoundError is caught and returned as an error response."""
from superset.commands.css.exceptions import CssTemplateNotFoundError
mock_command = MagicMock()
mock_command.run.side_effect = CssTemplateNotFoundError()
with patch(
"superset.commands.css.update.UpdateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
request = UpdateCssTemplateRequest(id=999, template_name="Ghost")
result = await client.call_tool(
"update_css_template", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["id"] is None
assert "not found" in data["error"]
@pytest.mark.asyncio
async def test_update_css_template_invalid_error(mcp_server: object) -> None:
"""CssTemplateInvalidError is caught and returned as an error response."""
from superset.commands.css.exceptions import CssTemplateInvalidError
mock_command = MagicMock()
mock_command.run.side_effect = CssTemplateInvalidError()
with patch(
"superset.commands.css.update.UpdateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
request = UpdateCssTemplateRequest(id=1, template_name="Valid Name")
result = await client.call_tool(
"update_css_template", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["id"] is None
assert data["error"] is not None
@pytest.mark.asyncio
async def test_update_css_template_update_failed(mcp_server: object) -> None:
"""CssTemplateUpdateFailedError is caught and returned as an error response."""
from superset.commands.css.exceptions import CssTemplateUpdateFailedError
mock_command = MagicMock()
mock_command.run.side_effect = CssTemplateUpdateFailedError()
with patch(
"superset.commands.css.update.UpdateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
request = UpdateCssTemplateRequest(id=1, css=".x { color: red; }")
result = await client.call_tool(
"update_css_template", {"request": request.model_dump()}
)
data = json.loads(result.content[0].text)
assert data["id"] is None
assert "Failed to update CSS template" in data["error"]
@pytest.mark.asyncio
async def test_update_css_template_unexpected_exception_is_reraised(
mcp_server: object,
) -> None:
"""Unexpected exceptions are re-raised (not swallowed as error responses)."""
from fastmcp.exceptions import ToolError
mock_command = MagicMock()
mock_command.run.side_effect = RuntimeError("unexpected database error")
with patch(
"superset.commands.css.update.UpdateCssTemplateCommand",
return_value=mock_command,
):
async with Client(mcp_server) as client:
with pytest.raises((RuntimeError, ToolError)):
await client.call_tool(
"update_css_template",
{"request": {"id": 1, "css": ".x { color: red; }"}},
)

View File

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

View File

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

View File

@@ -1,172 +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 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

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

View File

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

View File

@@ -1,245 +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 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,97 +536,6 @@ 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`.
@@ -728,7 +637,6 @@ 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"):
@@ -763,7 +671,6 @@ 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"):
@@ -827,7 +734,6 @@ 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()

View File

@@ -1,65 +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.
from types import SimpleNamespace
from flask import current_app
from pytest_mock import MockerFixture
from superset.tasks.schemas import TaskResponseSchema
def _task_with_error_properties() -> SimpleNamespace:
return SimpleNamespace(
properties_dict={
"is_abortable": True,
"progress_percent": 1.0,
"error_message": "boom",
"exception_type": "KeyError",
"stack_trace": 'Traceback (most recent call last):\n File "/app/x.py"',
}
)
def test_get_properties_hides_debug_fields_by_default(app_context: None) -> None:
"""
By default (SHOW_STACKTRACE disabled) the serialized task properties must
not disclose the stack trace or the raw exception class name (CWE-209),
while still returning consumer-safe fields like error_message.
"""
properties = TaskResponseSchema().get_properties(_task_with_error_properties())
assert "stack_trace" not in properties
assert "exception_type" not in properties
# consumer-safe fields are preserved
assert properties["error_message"] == "boom"
assert properties["is_abortable"] is True
assert properties["progress_percent"] == 1.0
def test_get_properties_exposes_debug_fields_when_show_stacktrace(
app_context: None, mocker: MockerFixture
) -> None:
"""
When SHOW_STACKTRACE is explicitly enabled, the debugging fields are
returned (parity with how Superset surfaces stack traces elsewhere).
"""
mocker.patch.dict(current_app.config, {"SHOW_STACKTRACE": True})
properties = TaskResponseSchema().get_properties(_task_with_error_properties())
assert properties["exception_type"] == "KeyError"
assert str(properties["stack_trace"]).startswith("Traceback")